File pinact-3.6.0.obscpio of Package pinact
07070100000000000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001500000000pinact-3.6.0/.claude07070100000001000081A4000000000000000000000001693FE2CD000000EF000000000000000000000000000000000000002300000000pinact-3.6.0/.claude/settings.json{
"hooks": {
"PostToolUse": [
{
"matcher": "^(Edit|Write|MultiEdit)$",
"hooks": [
{
"type": "command",
"command": "cmdx fmt && cmdx v"
}
]
}
]
}
}
07070100000002000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001900000000pinact-3.6.0/.clinerules07070100000003000081A4000000000000000000000001693FE2CD00000131000000000000000000000000000000000000003200000000pinact-3.6.0/.clinerules/01-project-guidelines.md# Project Guidelines Reference
For all project guidelines, conventions, and commands, please refer to [AI_GUIDE.md](../AI_GUIDE.md).
This includes:
- Language conventions
- Commit message format
- Code validation and testing commands
- Project structure
- Development workflow
- Error handling patterns
07070100000004000081A4000000000000000000000001693FE2CD000003C0000000000000000000000000000000000000002E00000000pinact-3.6.0/.clinerules/02-cline-specific.md# Cline-Specific Guidelines
## VS Code Integration
- Leverage VS Code's built-in features for file navigation and editing
- Use the integrated terminal for command execution
- Respect workspace settings and configurations
## File Operations
- Use VS Code's file explorer for navigation
- Prefer relative paths for portability
- Respect .gitignore patterns
## Error Reporting in VS Code
- Format errors with file paths as clickable links (e.g., `pkg/controller/run/parse_line.go:123`)
- Use VS Code's Problems panel format when appropriate
- Provide clear, actionable error messages in the output
## VS Code Terminal Usage
- Execute validation and test commands in the integrated terminal
- Show command output directly in the terminal
- Use VS Code's task runner integration when available
## Cline Chat Interface
- Use markdown formatting for better readability
- Keep responses concise and focused
- Provide file paths that VS Code can navigate to
07070100000005000081A4000000000000000000000001693FE2CD000002A5000000000000000000000000000000000000001B00000000pinact-3.6.0/.golangci.ymlversion: "2"
linters:
default: all
disable:
- depguard
- err113
- exhaustive
- exhaustruct
- godot
- godox
- ireturn
- lll
- musttag
- nlreturn
- nonamedreturns
- tagalign
- tagliatelle
- varnamelen
- wsl
- wsl_v5
- noinlineerr
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
- gofmt
- gofumpt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
07070100000006000081A4000000000000000000000001693FE2CD00000816000000000000000000000000000000000000001D00000000pinact-3.6.0/.goreleaser.yml---
version: 2
project_name: pinact
archives:
- name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
files:
- LICENSE
- README.md
- third_party_licenses/**/*
format_overrides:
- goos: windows
formats: [zip]
builds:
- binary: pinact
main: cmd/pinact/main.go
env:
- CGO_ENABLED=0
goos:
- windows
- darwin
- linux
goarch:
- amd64
- arm64
release:
prerelease: "true"
header: |
[Pull Requests](https://github.com/suzuki-shunsuke/pinact/pulls?q=is%3Apr+milestone%3A{{.Tag}}) | [Issues](https://github.com/suzuki-shunsuke/pinact/issues?q=is%3Aissue+milestone%3A{{.Tag}}) | https://github.com/suzuki-shunsuke/pinact/compare/{{.PreviousTag}}...{{.Tag}}
sboms:
- id: default
disable: false
homebrew_casks:
-
# NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the
# same kind. We will probably unify this in the next major version like it is done with scoop.
repository:
owner: suzuki-shunsuke
name: homebrew-pinact
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
# The project name and current git tag are used in the format string.
commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
homepage: https://github.com/suzuki-shunsuke/pinact
description: Pin GitHub Actions versions
license: MIT
skip_upload: true
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/pinact"]
end
scoops:
-
description: |
Pin GitHub Action versions by full length commit SHA.
Edit GitHub Workflow and Composite action files and pin versions of Actions and Reusable Workflows.
pinact can also update their versions and verify version annotations
license: MIT
skip_upload: true
repository:
owner: suzuki-shunsuke
name: scoop-bucket
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
07070100000007000081A4000000000000000000000001693FE2CD0000013F000000000000000000000000000000000000001A00000000pinact-3.6.0/.pinact.yaml# yaml-language-server: $schema=json-schema/pinact.json
# pinact - https://github.com/suzuki-shunsuke/pinact
version: 3
ignore_actions:
- name: actions/setup-java
ref: v3
- name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml
ref: .*
- name: peaceiris/.*
ref: .*
07070100000008000081A4000000000000000000000001693FE2CD000015F2000000000000000000000000000000000000001900000000pinact-3.6.0/AI_GUIDE.md# AI Assistant Guidelines for pinact
This document contains common guidelines for AI assistants working on the pinact project.
Individual AI-specific documents (like CLAUDE.md, CLINE.md) should reference this guide.
## Language
This project uses **English** for all code comments, documentation, and communication.
## Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/) specification:
### Format
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
### Common Types
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that do not affect the meaning of the code
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `test`: Adding missing tests or correcting existing tests
- `chore`: Changes to the build process or auxiliary tools
- `ci`: Changes to CI configuration files and scripts
### Examples
```
feat: add GitHub token management via keyring
fix: handle empty configuration file correctly
docs: add function documentation to controller package
chore(deps): update dependency aquaproj/aqua-registry to v4.403.0
```
## Code Validation
After making code changes, **always run** the following commands to validate and test:
### Validation (go vet)
```bash
cmdx v
```
This command runs `go vet ./...` to check for common Go mistakes.
### Testing
```bash
cmdx t
```
This command runs all tests in the project.
Both commands should pass before committing changes.
## Project Structure
```
pinact/
├── cmd/ # Main applications
├── pkg/ # Go packages
│ ├── cli/ # CLI interface layer
│ ├── config/ # Configuration management
│ ├── controller/# Business logic
│ ├── github/ # GitHub API integration
│ └── util/ # Utility functions
├── testdata/ # Test fixtures
├── json-schema/ # JSON schema definitions
└── scripts/ # Build and utility scripts
```
## Package Responsibilities
### pkg/cli
Command-line interface layer that handles command parsing, flag processing, and routing to appropriate subcommands.
### pkg/config
Configuration management including reading, parsing, and validating .pinact.yaml files.
### pkg/controller
Business logic layer containing:
- `run`: Core pinning logic for GitHub Actions
- `migrate`: Configuration schema migration
### pkg/github
GitHub API client integration and authentication management.
### pkg/util
Common utility functions used across the codebase.
## Testing
- Run all tests: `cmdx t` or `go test ./...`
- Run specific package tests: `go test ./pkg/controller/run`
- Generate coverage: `./scripts/coverage.sh`
## Dependencies
This project uses:
- [aqua](https://aquaproj.github.io/) for tool version management
- [cmdx](https://github.com/suzuki-shunsuke/cmdx) for task runner
- [goreleaser](https://goreleaser.com/) for releases
## Code Style Guidelines
1. Follow standard Go conventions
2. Use meaningful variable and function names
3. Add comments for exported functions and types
4. Keep functions focused and small
5. Handle errors explicitly
6. Use context for cancellation and timeouts
7. Always end files with a newline character
## Pull Request Process
1. Create a feature branch from `main`
2. Make changes and ensure `cmdx v` and `cmdx t` pass
3. Write clear commit messages following Conventional Commits
4. Create PR with descriptive title and body
5. Wait for CI checks to pass
6. Request review if needed
## Important Commands
```bash
# Validate code (go vet)
cmdx v
# Run tests
cmdx t
# Build the project
go build ./cmd/pinact
# Generate JSON schema
cmdx js
# Run pinact locally
go run ./cmd/pinact run
```
## GitHub Actions Integration
The project includes GitHub Actions for:
- Testing on multiple platforms
- Linting and validation
- Release automation
- Security scanning
## Configuration
pinact supports configuration via:
- `.pinact.yaml` or `.github/pinact.yaml`
- Command-line flags
- Environment variables
## Environment Variables
- `GITHUB_TOKEN`: GitHub access token for API calls
- `PINACT_LOG_LEVEL`: Log level (debug, info, warn, error)
- `PINACT_CONFIG`: Path to configuration file
- `PINACT_KEYRING_ENABLED`: Enable keyring for token storage
## Debugging
Enable debug logging:
```bash
export PINACT_LOG_LEVEL=debug
pinact run
```
## Common Tasks
### Adding a New Command
1. Create new package under `pkg/cli/`
2. Implement command structure with `urfave/cli/v3`
3. Add controller logic under `pkg/controller/`
4. Register command in `pkg/cli/runner.go`
5. Add tests for new functionality
### Updating Schema
1. Modify `pkg/config/config.go`
2. Update JSON schema: `cmdx js`
3. Update documentation in `docs/schema_v*.md`
4. Add migration logic if needed
## File Naming Conventions
- Go source files: lowercase with underscores (e.g., `parse_line.go`)
- Test files: append `_test.go` to the source file name
- Internal test files: append `_internal_test.go` for internal testing
## Error Handling
- Always check and handle errors explicitly
- Use `fmt.Errorf` with `%w` for wrapping errors
- Add context to errors to aid debugging
- Use structured logging with slog
## Documentation
- Add package-level documentation comments
- Document all exported functions, types, and constants
- Use examples in documentation where helpful
- Keep README and other docs up to date
## Resources
- [Project README](README.md)
- [Contributing Guidelines](CONTRIBUTING.md)
- [Installation Guide](INSTALL.md)
- [Usage Documentation](USAGE.md)
07070100000009000081A4000000000000000000000001693FE2CD00000466000000000000000000000000000000000000001700000000pinact-3.6.0/CLAUDE.md# Claude-Specific Guidelines for pinact
This document contains Claude-specific guidelines. For general project guidelines, see [AI_GUIDE.md](AI_GUIDE.md).
## Core Guidelines
All general project guidelines are documented in [AI_GUIDE.md](AI_GUIDE.md). Please refer to that document for:
- Language conventions
- Commit message format
- Code validation and testing
- Project structure
- Code style guidelines
- Common tasks and workflows
## Claude-Specific Notes
### Context Window Management
- Be mindful of context window limits when reading large files
- Use file offset and limit parameters when appropriate
- Summarize lengthy outputs to conserve context
### Tool Usage
- Prefer batch operations when possible to improve efficiency
- Use the Task tool for complex multi-step operations
- Always validate changes with `cmdx v` and `cmdx t`
### Communication Style
- Keep responses concise and to the point
- Focus on actionable information
- Avoid unnecessary explanations unless requested
## Quick Reference
For quick access to common commands and guidelines, see [AI_GUIDE.md](AI_GUIDE.md#important-commands).
0707010000000A000081A4000000000000000000000001693FE2CD0000035D000000000000000000000000000000000000001D00000000pinact-3.6.0/CONTRIBUTING.md# Contributing
Please read the following document.
- https://github.com/suzuki-shunsuke/oss-contribution-guide
## How To Develop
We use [aqua](https://aquaproj.github.io/) as a CLI version manager and [cmdx](https://github.com/suzuki-shunsuke/cmdx) as a task runner.
[How to install aqua](https://aquaproj.github.io/docs/install)
```sh
aqua i # Install development tools including cmdx
```
Show tasks:
```sh
cmdx help
```
Test:
```sh
cmdx t
```
Lint:
```sh
cmdx v # go vet
cmdx l # golangci-lint
```
## Add tests
In addition to Go's unit tests, we run integration tests in CI.
- [testdata](testdata)
- [workflow](https://github.com/suzuki-shunsuke/pinact/blob/b60761b24a99aa946c45623c2ef2e1e673c257cf/.github/workflows/wc-test.yaml#L34-L67)
If you change pinact's behaviour, please add tests.
Tests also make how the behaviour is changed clear.
0707010000000B000081A4000000000000000000000001693FE2CD00000BC5000000000000000000000000000000000000001800000000pinact-3.6.0/INSTALL.md# Install
pinact is written in Go. So you only have to install a binary in your `PATH`.
There are some ways to install pinact.
1. [Homebrew](#homebrew)
1. [Scoop](#scoop)
1. [aqua](#aqua)
1. [GitHub Releases](#github-releases)
1. [Build an executable binary from source code yourself using Go](#build-an-executable-binary-from-source-code-yourself-using-go)
## Homebrew
You can install pinact using [Homebrew](https://brew.sh/).
[Homebrew Core Formula: pinact](https://formulae.brew.sh/formula/pinact)
```sh
brew install pinact
```
Or
```sh
brew install suzuki-shunsuke/pinact/pinact
```
## Scoop
You can install pinact using [Scoop](https://scoop.sh/).
```sh
scoop bucket add suzuki-shunsuke https://github.com/suzuki-shunsuke/scoop-bucket
scoop install pinact
```
## aqua
You can install pinact using [aqua](https://aquaproj.github.io/).
```sh
aqua g -i suzuki-shunsuke/pinact
```
## Build an executable binary from source code yourself using Go
```sh
go install github.com/suzuki-shunsuke/pinact/v3/cmd/pinact@latest
```
## GitHub Releases
You can download an asset from [GitHub Releases](https://github.com/suzuki-shunsuke/pinact/releases).
Please unarchive it and install a pre built binary into `$PATH`.
### Verify downloaded assets from GitHub Releases
You can verify downloaded assets using some tools.
1. [GitHub CLI](https://cli.github.com/)
1. [slsa-verifier](https://github.com/slsa-framework/slsa-verifier)
1. [Cosign](https://github.com/sigstore/cosign)
### 1. GitHub CLI
You can install GitHub CLI by aqua.
```sh
aqua g -i cli/cli
```
```sh
version=v1.0.0
asset=pinact_darwin_arm64.tar.gz
gh release download -R suzuki-shunsuke/pinact "$version" -p "$asset"
gh attestation verify "$asset" \
-R suzuki-shunsuke/pinact \
--signer-workflow suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml
```
### 2. slsa-verifier
You can install slsa-verifier by aqua.
```sh
aqua g -i slsa-framework/slsa-verifier
```
```sh
version=v1.0.0
asset=pinact_darwin_arm64.tar.gz
gh release download -R suzuki-shunsuke/pinact "$version" -p "$asset" -p multiple.intoto.jsonl
slsa-verifier verify-artifact "$asset" \
--provenance-path multiple.intoto.jsonl \
--source-uri github.com/suzuki-shunsuke/pinact \
--source-tag "$version"
```
### 3. Cosign
You can install Cosign by aqua.
```sh
aqua g -i sigstore/cosign
```
```sh
version=v1.0.0
checksum_file="pinact_${version#v}_checksums.txt"
asset=pinact_darwin_arm64.tar.gz
gh release download "$version" \
-R suzuki-shunsuke/pinact \
-p "$asset" \
-p "$checksum_file" \
-p "${checksum_file}.pem" \
-p "${checksum_file}.sig"
cosign verify-blob \
--signature "${checksum_file}.sig" \
--certificate "${checksum_file}.pem" \
--certificate-identity-regexp 'https://github\.com/suzuki-shunsuke/go-release-workflow/\.github/workflows/release\.yaml@.*' \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$checksum_file"
cat "$checksum_file" | sha256sum -c --ignore-missing
```
0707010000000C000081A4000000000000000000000001693FE2CD00000430000000000000000000000000000000000000001500000000pinact-3.6.0/LICENSEMIT License
Copyright (c) 2023 Shunsuke Suzuki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
0707010000000D000081A4000000000000000000000001693FE2CD0000512F000000000000000000000000000000000000001700000000pinact-3.6.0/README.md# pinact
[](https://deepwiki.com/suzuki-shunsuke/pinact)
[NotebookLM](https://notebooklm.google.com/notebook/31982d97-104e-4778-9a8f-3b2c044a719d) | [Install](INSTALL.md) | [How to use](#how-to-use) | [Configuration](#configuration)
pinact is a CLI to edit GitHub Workflow and Composite action files and pin versions of Actions and Reusable Workflows.
pinact can also [update their versions](#update-actions), [verify version annotations](docs/codes/001.md), and [create reviews](#create-reviews).
```sh
pinact run
```
```diff
$ git diff
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 84bd67a..5d92e44 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -113,17 +113,17 @@ jobs:
needs: path-filter
permissions: {}
steps:
- - uses: actions/checkout@83b7061638ee4956cf7545a6f7efe594e5ad0247 # v3
- - uses: actions/setup-go@v4
+ - uses: actions/checkout@83b7061638ee4956cf7545a6f7efe594e5ad0247 # v3.5.1
+ - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0
- name: Cache Primes
id: cache-primes
- uses: actions/cache@v3.3.1
+ uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
with:
path: prime-numbers
key: ${{ runner.os }}-primes
actionlint:
- uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@v0.5.0
+ uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@b6a5f966d4504893b2aeb60cf2b0de8946e48504 # v0.5.0
with:
aqua_version: v2.3.4
permissions:
```
Creating reviews:

## :bulb: NotebookLM for pinact
[You can ask any questions about pinact to NotebookLM.](https://notebooklm.google.com/notebook/31982d97-104e-4778-9a8f-3b2c044a719d)
## Motivation
It is a good manner to pin GitHub Actions versions by commit hash.
GitHub tags are mutable so they have a substantial security and reliability risk.
See also [Security hardening for GitHub Actions - GitHub Docs](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions)
> Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release.
> Pinning to a particular SHA helps mitigate the risk of a bad actor adding a backdoor to the action's repository, as they would need to generate a SHA-1 collision for a valid Git object payload
:thumbsup:
```yaml
uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
```
:thumbsdown:
```yaml
uses: actions/cache@v3
```
```yaml
uses: actions/cache@v3.3.1
```
## Why not using Renovate's helpers:pinGitHubActionDigestsToSemver preset?
The Renovate preset [helpers:pinGitHubActionDigestsToSemver](https://docs.renovatebot.com/presets-helpers/#helperspingithubactiondigeststosemver) is useful, but pinact is still useful:
1. Renovate can't pin actions in pull requests before merging them.
If you use linters such as [ghalint](https://github.com/suzuki-shunsuke/ghalint) in CI, you need to pin actions before merging pull requests
(ref. [ghalint policy to enforce actions to be pinned](https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/policies/008.md))
2. Even if you use Renovate, sometimes you would want to update actions manually
3. pinact is useful for non Renovate users
4. [pinact supports verifying version annotations](https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/001.md)
## GitHub Access token
pinact calls GitHub REST API to get commit hashes and tags.
You can pass GitHub Access token via environment variable `GITHUB_TOKEN`.
If no GitHub Access token is passed, pinact calls GitHub REST API without access token.
About GitHub Enterprise Server, see also [GitHub Access Token for GHES](#github-access-token-for-ghes).
### Manage GitHub Access token using Keyring
pinact >= v3.1.0
You can manage a GitHub Access token using secret store such as [Windows Credential Manager](https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0), [macOS Keychain](https://en.wikipedia.org/wiki/Keychain_(software)), and [GNOME Keyring](https://wiki.gnome.org/Projects/GnomeKeyring).
1. Configure a GitHub Access token by `pinact token set` command:
```console
$ pinact token set
Enter a GitHub access token: # Input GitHub Access token
```
or you can also pass a GitHub Access token via standard input:
```sh
echo "<github access token>" | pinact token set -stdin
```
2. Enable the feature by setting the environment variable `PINACT_KEYRING_ENABLED`:
```sh
export PINACT_KEYRING_ENABLED=true
```
Note that if the environment variable `GITHUB_TOKEN` is set, this feature gets disabled.
You can remove a GitHub Access token from keyring by `pinact token rm` command:
```sh
pinact token rm
```
## How to use
Please run `pinact run` on a Git repository root directory, then target files are fixed.
```sh
pinact run
```
Default target files are:
```
.github/workflows/*.yml
.github/workflows/*.yaml
action.yml
action.yaml
*/action.yml
*/action.yaml
*/*/action.yml
*/*/action.yaml
*/*/*/action.yml
*/*/*/action.yaml
```
You can change target files by command line arguments or configuration files.
e.g.
```sh
pinact run example.yaml
```
### Update actions
[#663](https://github.com/suzuki-shunsuke/pinact/pull/663) pinact >= v1.1.0
You can update actions using the `-update (-u)` option:
```sh
pinact run -u
```
#### Skip recently released versions
[#1266](https://github.com/suzuki-shunsuke/pinact/pull/1266) pinact >= v3.5.0
You can skip recently released versions using the `--min-age` (`-m`) option or the environment variable `PINACT_MIN_AGE`.
This helps avoid updating to potentially unstable versions that haven't had time to prove their stability.
```sh
pinact run -u --min-age 7
```
or
```sh
export PINACT_MIN_AGE=7
pinact run -u
```
This command skips versions released within the last 7 days.
- For GitHub Releases, the `PublishedAt` date is checked
- For tags, the commit's `Committer.Date` is checked (requires additional API call)
### Fix example codes in documents
pinact can fix example codes in documents too.
```sh
pinact run README.md
```
### Create reviews

As of pinact v3.3.0, pinact can create reviews by GitHub API.
A GitHub access token with `pull_requests:write` permission is required.
```sh
pinact run \
-review \
-repo-owner <repository owner> \
-repo-name <repository name> \
-pr <pull request number> \
-sha <commit SHA to be reviewed>
```
If pinact is run via GitHub Actions `pull_request` event, options are auto-completed.
> [!WARNING]
> GitHub can't create pull request reviews on files not changed by the pull request.
> When pinact fails to create reviews, pinact outputs warning and creates [GitHub Actions error messages to log instead](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-an-error-message).
> You can ignore the warning like this:
> ```
> WARN[0004] create a review comment error="create a review comment: POST https://api.github.com/repos/szksh-lab-2/test-github-action/pulls/317/comments: 422 Validation Failed [{Resource:PullRequestReviewComment Field:pull_request_review_thread.path Code:invalid Message:} {Resource:PullRequestReviewComment Field:pull_request_review_thread.diff_hunk Code:missing_field Message:}]" line=" - uses: suzuki-shunsuke/watch-star-action@feat/first-pr" line_number=14 pinact_version=3.3.0-5 program=pinact review_pr_number=317 review_repo_name=test-github-action review_repo_owner=szksh-lab-2 review_sha=92f0b04efdc10acb793e78bdd1f70958dd3fd9a3 workflow_file=.github/workflows/watch.yaml
> ```

### Generate a configuration file `.pinact.yaml`
A configuration file is optional.
You can create a configuration file `.pinact.yaml` by `pinact init`.
```sh
pinact init
```
You can change the output path.
```sh
pinact init '.github/pinact.yaml'
```
About the configuration, please see [Configuration](#Configuration).
### Validation
`pinact >= v1.6.0` [#816](https://github.com/suzuki-shunsuke/pinact/pull/816)
Instead of fixing files, you can validate if actions are pinned by `--check` option:
```sh
pinact run --check
```
Using this option, pinact doesn't fix files.
If actions aren't pinned, the command fails.
```console
$ pinact run --check
ERRO[0000] parse a line action=actions/checkout@v2 error="action isn't pinned" pinact_version= program=pinact workflow_file=testdata/foo.yaml
ERRO[0000] parse a line action=actions/cache@v3.3.1 error="action isn't pinned" pinact_version= program=pinact workflow_file=testdata/foo.yaml
ERRO[0000] parse a line action=rharkor/caching-for-turbo@v1.6 error="action isn't pinned" pinact_version= program=pinact workflow_file=testdata/foo.yaml
ERRO[0000] parse a line action=actions/checkout@v3 error="action isn't pinned" pinact_version= program=pinact workflow_file=testdata/foo.yaml
ERRO[0000] parse a line action=actions/checkout@v3 error="action isn't pinned" pinact_version= program=pinact workflow_file=testdata/foo.yaml
ERRO[0000] parse a line action=suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@v0.5.0 error="action isn't pinned" pinact_version= program=pinact workflow_file=testdata/foo.yaml
$ echo $?
1
```
If `-check` is set, files aren't fixed and no diff is outputted.
If you want to fix files, please use `-fix` option.
```sh
pinact run -check -fix
```
And if you want to output diff, please use `-diff` option.
```sh
pinact run -check -diff
```
### Verify version annotations
Please see [the document](docs/codes/001.md).
### Output diff
```console
$ pinact run -diff
INFO[0000] action isn't pinned
.github/workflows/test.yaml:8
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
pinact_version=v3.0.0-local program=pinact
```
### -diff, -check, -fix options
The behaviour of `pinact run` command is changed by command line options `-diff`, `-check`, and `-fix`.
This is a table how the behaviour is changed by these options.
options | Fix files | Exit with code 1 if actions aren't pinned | Output changes
--- | --- | --- | ---
No option | o | | |
-check | | o | |
-diff | | | o
-check -diff | | o | o
-check -fix | o | o | o
-fix -diff | o | | o
## Fix or exclude only specific actions
[#1082](https://github.com/suzuki-shunsuke/pinact/pull/1082) pinact >= v3.4.0
You can fix only specific actions using the `-include (-i) <regular expression>` option.
You can also exclude only specific actions using the `-exclude (-e) <regular expression>` option.
e.g.
```sh
pinact run -i "actions/.*" -i "^aquaproj/aqua-installer$"
```
```sh
pinact run -e "actions/.*" -e "^aquaproj/aqua-installer$"
```
## GitHub Actions
https://github.com/suzuki-shunsuke/pinact-action
We develop GitHub Actions to pin GitHub Actions and reusable workflows by pinact.
## Configuration
A configuration file is optional.
pinact supports a configuration file `.pinact.yaml`, `.github/pinact.yaml`, `.pinact.yml` or `.github/pinact.yml`.
You can also specify the configuration file path by the environment variable `PINACT_CONFIG` or command line option `-c`.
As of pinact v2.2.0, pinact configuration file has a schema version.
```yaml
version: 3
```
In general, you should use the latest schema version.
### Schema v3 (latest)
pinact v2.2.0 or later supports this version.
.pinact.yaml
e.g.
```yaml
version: 3
files:
- pattern: .github/workflows/*.yml
- pattern: .github/workflows/*.yaml
- pattern: .github/actions/*/action.yml
- pattern: .github/actions/*/action.yaml
ignore_actions:
# slsa-framework/slsa-github-generator doesn't support pinning version
# > Invalid ref: 68bad40844440577b33778c9f29077a3388838e9. Expected ref of the form refs/tags/vX.Y.Z
# https://github.com/slsa-framework/slsa-github-generator/issues/722
- name: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml
ref: "v\\d+\\.\\d+\\.\\d+"
- name: suzuki-shunsuke/.*
ref: main
# GitHub Enterprise Server Support
ghes:
api_url: https://ghes.example.com
fallback: true # optional, default is false
```
#### `files`
This is optional.
A list of target files.
#### `files[].pattern`
This is required.
A glob pattern of target files.
[Go's path/filepath#Glob](https://pkg.go.dev/path/filepath#Glob) is used.
A relative path from pinact's configuration file.
If files are passed via positional command line arguments, the configuration is ignored.
e.g.
```yaml
files:
- pattern: .github/workflows/*.yml
- pattern: .github/workflows/*.yaml
- pattern: README.md
```
#### `ignore_actions`
This is optional. A list of ignored actions and reusable workflows.
#### `ignore_actions[].name`
This is required.
A regular expression of ignored actions and reusable workflows.
```yaml
ignore_actions:
- name: actions/.*
ref: main
```
> [!WARNING]
> Regular expressions must match with action names exactly.
> For instance, `name: actions/` doesn't match with `actions/checkout`
Regarding regular expressions, [Go's regexp package is used.](https://pkg.go.dev/regexp)
#### `ignore_actions[].ref`
This is required.
A regular expression of ignored action versions (branch, tag, or commit hash).
> [!WARNING]
> Regular expressions must match with action names exactly.
> For instance, `ref: main` doesn't match with `malicious-main`
#### `ghes`
[See GitHub Enterprise Support](#github-enterprise-server-ghes-support).
### Old Schemas
Please see [here](docs/old_schema.md).
### JSON Schema
- [pinact.json](json-schema/pinact.json)
- https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json
If you look for a CLI tool to validate configuration with JSON Schema, [ajv-cli](https://ajv.js.org/packages/ajv-cli.html) is useful.
```sh
ajv --spec=draft2020 -s json-schema/pinact.json -d pinact.yaml
```
#### Input Complementation by YAML Language Server
[Please see the comment too.](https://github.com/szksh-lab/.github/issues/67#issuecomment-2564960491)
Version: `main`
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/main/json-schema/pinact.json
```
Or pinning version:
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/v1.1.2/json-schema/pinact.json
```
## Q. Why doesn't pinact pin some actions?
In some cases pinact doesn't pin versions intentionally, which may confuse you.
So we describe the reason here.
pinact doesn't pin actions whose versions aren't semver (e.g. `main`, `master`, `release/v1`).
This is because pinact is designed as a safe tool so that it doesn't change workflows behaviour.
pinact pins actions but doesn't change SHA of actions at the moment when pinact pins versions.
This design enables you to accept changes by pinact safely.
For instance, pinact changes the version `v1` to `v1.1.0` if their SHA are equivalent.
If there are no semver whose SHA is same with `v1`, pinact doesn't change the version.
And pinact doesn't change versions which aren't semver.
For instance, pinact doesn't change the version `main`.
```yaml
uses: actions/checkout@main
```
We don't want to pin `main` to full commit length SHA like the following because we can't update this following semantic versioning.
```yaml
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 # main
```
Tools like Renovate can update the SHA, but it's not safe at all as `main` branch isn't stable.
And we don't want to change `main` to the latest semver like the following because SHA is changed and workflows may be broken.
```yaml
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
```
We don't want to pin branches as SHA of branches is changed.
pinact doesn't check if a version is a tag or a branch because we would like to reduce the number of API calls as much as possible.
If a version isn't semver, pinact judges it may be a branch so pinact doesn't pin it.
Please see also [#926](https://github.com/suzuki-shunsuke/pinact/issues/926).
## GitHub Enterprise Server (GHES) Support
v3.6.0 [#839](https://github.com/suzuki-shunsuke/pinact/issues/839) [#1275](https://github.com/suzuki-shunsuke/pinact/pull/1275)
pinact also supports pinning versions of GitHub Actions hosted on GitHub Enterprise Server (GHES).
If the GHES support is enabled, pinact searches actions in GHES.
### Fallback to github.com
The fallback to github.com is disabled by default.
All actions are searched on the GHES instance only.
If the fallback is enabled, repositories of actions are first searched on the GHES instance. If repositoires are not found (404), pinact falls back to github.com. This is suitable when [GitHub Connect is enabled](https://docs.github.com/en/enterprise-server@3.19/admin/managing-github-actions-for-your-enterprise/managing-access-to-actions-from-githubcom/enabling-automatic-access-to-githubcom-actions-using-github-connect).
### GitHub Access Token for GHES
Set a GitHub Access Token for GHES using one of the following environment variables (checked in order):
1. `GHES_TOKEN`
2. `GITHUB_TOKEN_ENTERPRISE`
3. `GITHUB_ENTERPRISE_TOKEN`
```sh
export GHES_TOKEN=xxx
```
`GITHUB_TOKEN` is used for github.com.
### Configuration File For GHES
GHES configuration is required via configuration file or environment variables.
The configuration file takes precedence over the environment variables.
```yaml
ghes:
api_url: https://ghes.example.com
fallback: true # optional, default is false
```
- `api_url`: API URL of the GHES instance. Can also be set via environment variables.
- `fallback`: Whether to fallback to github.com when a repository is not found on GHES. Default is `false`.
### Environment Variables For GHES
You can also configure GHES using environment variables instead of a configuration file.
- `PINACT_GHES_API_URL`
- `PINACT_GHES_FALLBACK`
```sh
export PINACT_GHES_API_URL=https://ghes.example.com
export PINACT_GHES_FALLBACK=true
```
If `PINACT_GHES_API_URL` is not set, `GITHUB_API_URL` will be used instead.
This is convenient when running on GitHub Actions hosted on GHES.
### Conditions for Enabling GHES
GHES mode is enabled when any of the following conditions are met:
1. `ghes.api_url` is configured in the configuration file
2. `PINACT_GHES_API_URL` environment variable is set
3. `GITHUB_API_URL` environment variable is set and is not `https://api.github.com`
## See also
- [Renovate github-actions Manager - Additional Information](https://docs.renovatebot.com/modules/manager/github-actions/#additional-information)
- [sethvargo/ratchet](https://github.com/sethvargo/ratchet) is a great tool, but there are [known issues](https://github.com/sethvargo/ratchet#known-issues).
## LICENSE
[MIT](LICENSE)
0707010000000E000081A4000000000000000000000001693FE2CD000013E9000000000000000000000000000000000000001600000000pinact-3.6.0/USAGE.md# Usage
<!-- This is generated by scripts/generate-usage.sh. Don't edit this file directly. -->
```console
$ pinact --help
NAME:
pinact - Pin GitHub Actions versions. https://github.com/suzuki-shunsuke/pinact
USAGE:
pinact [global options] [command [command options]]
VERSION:
3.5.0
COMMANDS:
init Create .pinact.yaml if it doesn't exist
run Pin GitHub Actions versions
migrate Migrate .pinact.yaml
token Manage GitHub Access token
version Show version
help, h Shows a list of commands or help for one command
completion Output shell completion script for bash, zsh, fish, or Powershell
GLOBAL OPTIONS:
--log-level string log level [$PINACT_LOG_LEVEL]
--config string, -c string configuration file path [$PINACT_CONFIG]
--help, -h show help
--version, -v print the version
```
## pinact init
```console
$ pinact init --help
NAME:
pinact init - Create .pinact.yaml if it doesn't exist
USAGE:
pinact init
DESCRIPTION:
Create .pinact.yaml if it doesn't exist
$ pinact init
You can also pass configuration file path.
e.g.
$ pinact init .github/pinact.yaml
OPTIONS:
--help, -h show help
```
## pinact run
```console
$ pinact run --help
NAME:
pinact run - Pin GitHub Actions versions
USAGE:
pinact run
DESCRIPTION:
If no argument is passed, pinact searches GitHub Actions workflow files from .github/workflows.
$ pinact run
You can also pass workflow file paths as arguments.
e.g.
$ pinact run .github/actions/foo/action.yaml .github/actions/bar/action.yaml
OPTIONS:
--verify, -v Verify if pairs of commit SHA and version are correct
--check Exit with a non-zero status code if actions are not pinned. If this is true, files aren't updated
--update, -u Update actions to latest versions
--review Create reviews
--fix Fix code. By default, this is true. If -check or -diff is true, this is false by default
--diff Output diff. By default, this is false
--repo-owner string GitHub repository owner [$GITHUB_REPOSITORY_OWNER]
--repo-name string GitHub repository name
--sha string Commit SHA to be reviewed
--pr int GitHub pull request number (default: 0)
--include string, -i string [ --include string, -i string ] A regular expression to fix actions
--exclude string, -e string [ --exclude string, -e string ] A regular expression to exclude actions
--min-age int, -m int Skip versions released within the specified number of days (requires -u) (default: 0)
--help, -h show help
```
## pinact migrate
```console
$ pinact migrate --help
NAME:
pinact migrate - Migrate .pinact.yaml
USAGE:
pinact migrate
DESCRIPTION:
Migrate the version of .pinact.yaml
$ pinact migrate
OPTIONS:
--help, -h show help
```
## pinact token
```console
$ pinact token --help
NAME:
pinact token - Manage GitHub Access token
USAGE:
pinact token [command [command options]]
DESCRIPTION:
Manage GitHub Access token by keyring.
COMMANDS:
set Set GitHub Access token
remove, rm Remove GitHub Access token
OPTIONS:
--help, -h show help
```
### token set
```console
$ token set --help
NAME:
pinact token set - Set GitHub Access token
USAGE:
pinact token set
DESCRIPTION:
Set GitHub Access token to keyring.
OPTIONS:
--stdin Read GitHub Access token from stdin
--help, -h show help
```
### token remove
```console
$ token remove --help
NAME:
pinact token remove - Remove GitHub Access token
USAGE:
pinact token remove
DESCRIPTION:
Remove GitHub Access token from keyring.
OPTIONS:
--help, -h show help
```
## pinact version
```console
$ pinact version --help
NAME:
pinact version - Show version
USAGE:
pinact version
OPTIONS:
--json, -j Output version in JSON format
--help, -h show help
```
## pinact completion
```console
$ pinact completion --help
NAME:
pinact completion - Output shell completion script for bash, zsh, fish, or Powershell
USAGE:
pinact completion
DESCRIPTION:
Output shell completion script for bash, zsh, fish, or Powershell.
Source the output to enable completion.
# .bashrc
source <(pinact completion bash)
# .zshrc
source <(pinact completion zsh)
# fish
pinact completion fish > ~/.config/fish/completions/pinact.fish
# Powershell
Output the script to path/to/autocomplete/pinact.ps1 an run it.
OPTIONS:
--help, -h show help
```
0707010000000F000081A4000000000000000000000001693FE2CD00000043000000000000000000000000000000000000001900000000pinact-3.6.0/_typos.toml[default.extend-words]
ERRO = "ERRO"
intoto = "intoto"
typ = "typ"
07070100000010000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001200000000pinact-3.6.0/aqua07070100000011000081A4000000000000000000000001693FE2CD00003C68000000000000000000000000000000000000002600000000pinact-3.6.0/aqua/aqua-checksums.json{
"checksums": [
{
"id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_darwin_amd64.tar.gz",
"checksum": "D345DE5C7DCBD8E258D568CA40786768BB654CBA62F54CB8CA83C2C90A9D4422",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_darwin_arm64.tar.gz",
"checksum": "3B53CDFF2A1C66792329D91A914276E98EFBE548901978FE42B991EFC5DF90CF",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_linux_amd64.tar.gz",
"checksum": "6AA9A7B9E53C0F06E2D79FB24641CC1C856BB41702C282A577691F54BAD94996",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_linux_arm64.tar.gz",
"checksum": "CCD95833D4124F0E30925033908934978086727AF69585F015F28A4F355FDA1B",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_windows_amd64.zip",
"checksum": "74D57DACA2A9D08F0D470D3C4C11E8B98ED4A5EE5ABB70F1ED9D207D319FCF51",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-aarch64-apple-darwin.tar.gz",
"checksum": "1EA9ED6520B94D0E1148942E3EF80A997FF8DB856E1389EDAA9A5BDAFF658FA4",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-aarch64-unknown-linux-musl.tar.gz",
"checksum": "349B2C3F7C7FBA125E978DF232FAA9C5A57C33AA144F88CBC250C8C6D3E8E054",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-x86_64-apple-darwin.tar.gz",
"checksum": "51368551A37E15464438EA5C95AD29CB7239BFDEFD69EE9A9BE5FF3D45FC4D19",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-x86_64-pc-windows-msvc.zip",
"checksum": "F13426420749FAE31136E15A245C8EB144D6D3D681B3300D54D1A129999A140D",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/crate-ci/typos/v1.40.0/typos-v1.40.0-x86_64-unknown-linux-musl.tar.gz",
"checksum": "485405D0A92871F45EAD0703D23C04AE6969AD4A6E5799794F55EB04B9F07801",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-darwin-amd64.tar.gz",
"checksum": "6966554840A02229A14C52641BC38C2C7A14D396F4C59BA0C7C8BB0675CA25C9",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-darwin-arm64.tar.gz",
"checksum": "6CE86A00E22B3709F7B994838659C322FDC9EAE09E263DB50439AD4F6EC5785C",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-linux-amd64.tar.gz",
"checksum": "CE46A1F1D890E7B667259F70BB236297F5CF8791A9B6B98B41B283D93B5B6E88",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-linux-arm64.tar.gz",
"checksum": "7028E810837722683DAB679FB121336CFA303FECFF39DFE248E3E36BC18D941B",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-windows-amd64.zip",
"checksum": "D48F456944C5850CA408FEB0CAC186345F0A6D8CF5DC31875C8F63D3DFF5EE4C",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-windows-arm64.zip",
"checksum": "E5FC39E0F3FE817F093B5467BFC60D2A9D1292DE930B29322D2A1F8AFF2A3BBF",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Darwin_all.tar.gz",
"checksum": "C7B5C26953E59B7E4B50913738C7FF2C371C95B5145BD0A2F93CFA5571D3BE97",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Linux_arm64.tar.gz",
"checksum": "97051DE56BDCC4A76B2AA552FA85B633EBFFEA47B44BED85CD3580F12FC82651",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Linux_x86_64.tar.gz",
"checksum": "04764528D7344BC5EFAE80EF62467480578A37DB0BB98EA2CEE185E04AEB1A7D",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Windows_arm64.zip",
"checksum": "B4BAB00ED850E7E30054A462587FB7076A548CC137C5587694D2B8E5E65DFFA6",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Windows_x86_64.zip",
"checksum": "25CB285AB0481A9456CA8EF8E39147D4CF018F0990BC560EFA3ED2A14E9D7DA7",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_darwin_amd64",
"checksum": "4172B912EC514038605F334FEF9ED7B3F12CA3E40024CB0A622EAB3073A55E57",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_darwin_arm64",
"checksum": "C241FB742599A6CB0563D7377F59DEF65D451B23DD718DBC6DDF4AB5E695E8F1",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_linux_amd64",
"checksum": "72CF61B12FEF91EAB6DF6DB4A4284F30616B5EAD330112E28A1FA1CB15E57339",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_linux_arm64",
"checksum": "5ACAA5A554050F55FC81EF02A8B0D14AB6B3C058A84513885286DC52D3451645",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/mvdan/gofumpt/v0.9.2/gofumpt_v0.9.2_windows_amd64.exe",
"checksum": "067236B55A8EF4547DDC7D78FBB7A38169DE15BAB02A1763CDE6A132C59DD35C",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_arm64.tar.gz",
"checksum": "C28DEF83AF6C5AA8728D6D18160546AFD3E5A219117715A2C6C023BD16F14D10",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_x86_64.tar.gz",
"checksum": "9BAADB110C87F22C55688CF4A966ACCE3006C7A4A962732D6C8B45234C454C6E",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_arm64.tar.gz",
"checksum": "B6AFF657B39E9267A258E8FA66D616F7221AEC5975D0251DAC76043BAD0FA177",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_x86_64.tar.gz",
"checksum": "AD5CE7D5FFA52AAA7EC8710A8FA764181B6CECAAB843CC791E1CCE1680381569",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_arm64.tar.gz",
"checksum": "72ABE9907454C5697777CFFF1D0D03DB8F5A9FD6950C609CA397A90D41AB65D7",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_x86_64.tar.gz",
"checksum": "97C733E492DEC1FD83B9342C25A384D5AB6EBFA72B6978346E9A436CAD1853F6",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_darwin_amd64.tar.gz",
"checksum": "F89A910E90E536F60DF7C504160247DB01DD67CAB6F08C064C1C397B76C91A79",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_darwin_arm64.tar.gz",
"checksum": "855E49E823FC68C6371FD6967E359CDE11912D8D44FED343283C8E6E943BD789",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_linux_amd64.tar.gz",
"checksum": "233B280D05E100837F4AF1433C7B40A5DCB306E3AA68FB4F17F8A7F45A7DF7B4",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_linux_arm64.tar.gz",
"checksum": "6B82A3B8C808BF1BCD39A95ACED22FC1A026EEF08EDE410F81E274AF8DEADBBC",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_windows_amd64.zip",
"checksum": "7C8B10A93723838BC3533F6E1886D868FDBB109B81606EBE6D1A533D11D8E978",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_windows_arm64.zip",
"checksum": "7ACA9BF09EEDF0A743E08C7CB9F1712467A7324A9342A029AE4536FB4BE95C25",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-darwin-amd64",
"checksum": "6C75981E85E081A73F0B4087F58E0AD5FD4712C71B37FA0B6AD774C1F965BAFA",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-darwin-arm64",
"checksum": "38349E45A8BB0D1ED3A7AFFB8BDD2E9D597CEE08B6800C395A926B4D9ADB84D2",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-linux-amd64",
"checksum": "052363A0E23E2E7ED53641351B8B420918E7E08F9C1D8A42A3DD3877A78A2E10",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-linux-arm64",
"checksum": "81398231362031E3C7AFD6A7508C57049460CD7E02736F1EBE89A452102253E5",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-windows-amd64.exe",
"checksum": "2593655025B52B5B1C99E43464459B645A3ACBE5D4A5A9F3A766E77BEEC5A441",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_amd64.tar.gz",
"checksum": "768B8517666A15D25A6870307231416016FC1165F8A1C1743B6AACDBAC7A5FAC",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_arm64.tar.gz",
"checksum": "FBD7DADDBB65ABD0DE5C6B898F2219588C7D1A71DF6808137D0A628858E7777B",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_amd64.tar.gz",
"checksum": "40BC7B5F472211B22C4786D55F6859FA8093F1A373FF40A2DCCD29BD3D11CF96",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_arm64.tar.gz",
"checksum": "691EB4CC3929A5E065F7C2F977CEE8306D817CB0F8DE9D5B4B4ED38C027CEC41",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_amd64.zip",
"checksum": "4452010897556935E3F94A11AF2B2889563E05073A6DEA72FCF40B83B7F4AE5B",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_arm64.zip",
"checksum": "156D02F4E784E237B0661464D6FF76D6C4EFC4E01F858F8A9734364CD41BC98E",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_darwin_amd64.tar.gz",
"checksum": "BA9604B55E512447803A2AD754749A82E3048DCF27B630D2FC068F9C9AB221F2",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_darwin_arm64.tar.gz",
"checksum": "3DE0A438EBB34A88F9D6AF23FAC75B698E04597DDD7098115C2273414FF31527",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_linux_amd64.tar.gz",
"checksum": "977555B7142CC057AAD4663679772510A9ED3F1687F26F2359D18BB6B67314FE",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_linux_arm64.tar.gz",
"checksum": "9B5C41332EE0C83C36D003A95813320A41DD0BE9186C4EB1DCB119908ADBC0A6",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_windows_amd64.zip",
"checksum": "8D32A5EC2623A52A1EE5755131C6C73E5593E22B5795B1918F319C08970073E0",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.4/ghalint_1.5.4_windows_arm64.zip",
"checksum": "C816F297D658E466F59BBCADC521C6F7053C2DFBA89AFBABA8D2E542AFD6A72E",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_darwin_amd64.tar.gz",
"checksum": "5733518D44F7EAE2B4B16B0EA6617597C89B2EB084A4E6101A1DC03716266B6C",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_darwin_arm64.tar.gz",
"checksum": "E854EE0AA0DD83273D3B68E335BC025FDA721A32A3373091DFDEC3A582D1EC41",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_linux_amd64.tar.gz",
"checksum": "F54EC24CE1C344B611F6D80155396101D38E72B7F88E2CA8B9BFADC16307AE35",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_linux_arm64.tar.gz",
"checksum": "EC8D94494C70F4285A39375F385A4E52F945C597A3A5361ADF3DD9F1C8263CE4",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_windows_amd64.zip",
"checksum": "4ABEBC98AF160C06C988F1421F0CF991FBDBFB6789CC1681C5C8E33C48A70160",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/nllint/v1.0.0/nllint_windows_arm64.zip",
"checksum": "7493FF32F027507BDA9E9308E4AA25D640AA16B0287F7656A7C0A61AF907D270",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/pinact/v3.5.0/pinact_darwin_amd64.tar.gz",
"checksum": "D86DF98BE282A613CE82D07FAAD4AD070312B35B03E66A5D468C53317565D3C3",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/pinact/v3.5.0/pinact_darwin_arm64.tar.gz",
"checksum": "A51B767D839C19C82B773872DA376A430AF51B6C9410F11CA58CFA6F4E28EAB0",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/pinact/v3.5.0/pinact_linux_amd64.tar.gz",
"checksum": "BACC6DBF388CB09C13584A305BE83AEF938A0BC5CE91B738BE2F01C16C0E0B37",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/pinact/v3.5.0/pinact_linux_arm64.tar.gz",
"checksum": "11B8690D2605329C60476EEC31E100410F0D8689D3EC810D462AE6525270DC79",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/pinact/v3.5.0/pinact_windows_amd64.zip",
"checksum": "F3908FA9CF85F9DF482C7D1F2D8DE45A0F3258378752ABFCFEDAB2F8FA9E38D8",
"algorithm": "sha256"
},
{
"id": "github_release/github.com/suzuki-shunsuke/pinact/v3.5.0/pinact_windows_arm64.zip",
"checksum": "DA18ABF23EB0C16E6849F814118453104661883FE7BB6CC0F55A73AEDC6BAE4E",
"algorithm": "sha256"
},
{
"id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.446.0/registry.yaml",
"checksum": "F89F41662F54892E9E06168596845AA2C84865B6ED9782B527C47622BE3035E3C126C51D80BFFF76B41C4150D41FF1F70F6B521EA52FB6B4933D6170CACEF9F3",
"algorithm": "sha512"
}
]
}
07070100000012000081A4000000000000000000000001693FE2CD00000161000000000000000000000000000000000000001C00000000pinact-3.6.0/aqua/aqua.yaml---
# yaml-language-server: $schema=https://raw.githubusercontent.com/aquaproj/aqua/v2.55.3/json-schema/aqua-yaml.json
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
checksum:
enabled: true
require_checksum: true
registries:
- type: standard
ref: v4.446.0 # renovate: depName=aquaproj/aqua-registry
import_dir: imports
07070100000013000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001A00000000pinact-3.6.0/aqua/imports07070100000014000081A4000000000000000000000001693FE2CD0000002C000000000000000000000000000000000000002A00000000pinact-3.6.0/aqua/imports/actionlint.yamlpackages:
- name: rhysd/actionlint@v1.7.9
07070100000015000081A4000000000000000000000001693FE2CD00000030000000000000000000000000000000000000002400000000pinact-3.6.0/aqua/imports/cmdx.yamlpackages:
- name: suzuki-shunsuke/cmdx@v2.0.2
07070100000016000081A4000000000000000000000001693FE2CD0000002B000000000000000000000000000000000000002600000000pinact-3.6.0/aqua/imports/cosign.yamlpackages:
- name: sigstore/cosign@v3.0.3
07070100000017000081A4000000000000000000000001693FE2CD00000033000000000000000000000000000000000000002700000000pinact-3.6.0/aqua/imports/ghalint.yamlpackages:
- name: suzuki-shunsuke/ghalint@v1.5.4
07070100000018000081A4000000000000000000000001693FE2CD0000002E000000000000000000000000000000000000002B00000000pinact-3.6.0/aqua/imports/go-licenses.yamlpackages:
- name: google/go-licenses@v2.0.1
07070100000019000081A4000000000000000000000001693FE2CD00000029000000000000000000000000000000000000002700000000pinact-3.6.0/aqua/imports/gofumpt.yamlpackages:
- name: mvdan/gofumpt@v0.9.2
0707010000001A000081A4000000000000000000000001693FE2CD00000032000000000000000000000000000000000000002D00000000pinact-3.6.0/aqua/imports/golangci-lint.yamlpackages:
- name: golangci/golangci-lint@v2.7.2
0707010000001B000081A4000000000000000000000001693FE2CD00000032000000000000000000000000000000000000002900000000pinact-3.6.0/aqua/imports/goreleser.yamlpackages:
- name: goreleaser/goreleaser@v2.13.1
0707010000001C000081A4000000000000000000000001693FE2CD00000032000000000000000000000000000000000000002600000000pinact-3.6.0/aqua/imports/nllint.yamlpackages:
- name: suzuki-shunsuke/nllint@v1.0.0
0707010000001D000081A4000000000000000000000001693FE2CD00000032000000000000000000000000000000000000002600000000pinact-3.6.0/aqua/imports/pinact.yamlpackages:
- name: suzuki-shunsuke/pinact@v3.5.0
0707010000001E000081A4000000000000000000000001693FE2CD00000030000000000000000000000000000000000000002900000000pinact-3.6.0/aqua/imports/reviewdog.yamlpackages:
- name: reviewdog/reviewdog@v0.21.0
0707010000001F000081A4000000000000000000000001693FE2CD00000029000000000000000000000000000000000000002400000000pinact-3.6.0/aqua/imports/syft.yamlpackages:
- name: anchore/syft@v1.38.2
07070100000020000081A4000000000000000000000001693FE2CD0000002B000000000000000000000000000000000000002500000000pinact-3.6.0/aqua/imports/typos.yamlpackages:
- name: crate-ci/typos@v1.40.0
07070100000021000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001100000000pinact-3.6.0/cmd07070100000022000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002000000000pinact-3.6.0/cmd/gen-jsonschema07070100000023000081A4000000000000000000000001693FE2CD00000197000000000000000000000000000000000000002800000000pinact-3.6.0/cmd/gen-jsonschema/main.gopackage main
import (
"fmt"
"log"
"github.com/suzuki-shunsuke/gen-go-jsonschema/jsonschema"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
)
func main() {
if err := core(); err != nil {
log.Fatal(err)
}
}
func core() error {
if err := jsonschema.Write(&config.Config{}, "json-schema/pinact.json"); err != nil {
return fmt.Errorf("create or update a JSON Schema: %w", err)
}
return nil
}
07070100000024000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001800000000pinact-3.6.0/cmd/pinact07070100000025000081A4000000000000000000000001693FE2CD000000CD000000000000000000000000000000000000002000000000pinact-3.6.0/cmd/pinact/main.gopackage main
import (
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli"
"github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave"
)
var version = ""
func main() {
urfave.Main("pinact", version, cli.Run)
}
07070100000026000081A4000000000000000000000001693FE2CD000005C2000000000000000000000000000000000000001700000000pinact-3.6.0/cmdx.yaml---
# the configuration file of cmdx - task runner
# https://github.com/suzuki-shunsuke/cmdx
tasks:
- name: test
short: t
description: test
usage: test
script: go test ./... -race -covermode=atomic
- name: vet
short: v
description: go vet
usage: go vet
script: go vet ./...
- name: lint
short: l
description: lint the go code
usage: lint the go code
script: golangci-lint run
- name: coverage
short: c
description: coverage test
usage: coverage test
script: "bash scripts/coverage.sh {{.target}}"
args:
- name: target
- name: install
short: i
description: Build and install pinact
usage: Build and install pinact by "go install" command
script: |
sha=""
if git diff --quiet; then
sha=$(git rev-parse HEAD)
fi
go install \
-ldflags "-X main.version=v3.0.0-local -X main.commit=$sha -X main.date=$(date +"%Y-%m-%dT%H:%M:%SZ%:z" | tr -d '+')" \
./cmd/pinact
- name: run
description: Run pinact via go run
usage: Run pinact via go run
script: |
go run ./cmd/pinact {{._builtin.args_string}}
- name: usage
description: Generate USAGE.md
usage: Generate USAGE.md
script: bash scripts/generate-usage.sh
- name: js
description: Generate JSON Schema
usage: Generate JSON Schema
script: "go run ./cmd/gen-jsonschema"
- name: fmt
description: Format code
usage: Format code
script: |
git ls-files | grep -E "\.go$" | xargs gofumpt -l -w
git ls-files | xargs nllint -f -s
07070100000027000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001200000000pinact-3.6.0/docs07070100000028000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001800000000pinact-3.6.0/docs/codes07070100000029000081A4000000000000000000000001693FE2CD00000707000000000000000000000000000000000000001F00000000pinact-3.6.0/docs/codes/001.md# Verify version annotations
pinact >= v0.1.3
Please see the following code.
```yaml
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v3.5.1
```
You would assume the version of the action is v3.5.1 because the version annotation is "v3.5.1".
But the actual version is v2.7.0 because "ee0669bd1cc54295c223e0bb666b733df41de1c5" is the commit hash of v2.7.0.
Please check releases.
- https://github.com/actions/checkout/releases/tag/v3.5.1
- https://github.com/actions/checkout/releases/tag/v2.7.0
This indicates version annotations aren't necessarily correct.
Especially, attackers can specify a full commit SHA including a malicious code while setting a safe tag to the version annotation.
If a pull request includes changes of GitHub Actions, you should verify version annotations.
pinact v0.1.3 or newer can verify version annotations using `pinact run`'s `--verify` option.
This verification works only if the version annotation is semver and the version is full commit hash like the above example.
This option gets a full commit hash from a version annotation by GitHub API and compares it with the version.
e.g.
```console
$ pinact run --verify testdata/bar.yaml
ERRO[0000] parse a line action=actions/checkout action_version=ee0669bd1cc54295c223e0bb666b733df41de1c5 commit_hash_of_version_annotation=83b7061638ee4956cf7545a6f7efe594e5ad0247 error="verify the version annotation: action_version must be equal to commit_hash_of_version_annotation" help_docs="https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/001.md" pinact_version= program=pinact version_annotation=v3.5.1 workflow_file=testdata/bar.yaml
```
Note that `--verify` option calls GitHub API to verify version annotations, which may cause API rate limiting.
0707010000002A000081A4000000000000000000000001693FE2CD00000184000000000000000000000000000000000000001F00000000pinact-3.6.0/docs/codes/002.md# schema version is required
As of pinact v3, the `version` field is required in `.pinact.yaml`.
```yaml
version: 3
```
Please use the latest version as much as possible.
You can migrate the configuration file by `pinact migrate` command.
```sh
pinact migrate
```
Please see release notes of major updates too.
- [v3](https://github.com/suzuki-shunsuke/pinact/releases/tag/v3.0.0)
0707010000002B000081A4000000000000000000000001693FE2CD00000247000000000000000000000000000000000000001F00000000pinact-3.6.0/docs/codes/003.md# this version was abandoned. Please update the schema version
This error means the schema version of your configuration file `.pinact.yaml` is old, so your pinact doesn't support the version.
You need to update the schema version, or downgrade pinact.
Please use the latest version of pinact and the schema version as much as possible.
Maybe you should update pinact.
You can migrate the configuration file by `pinact migrate` command.
```sh
pinact migrate
```
Please see release notes of major updates too.
- [v3](https://github.com/suzuki-shunsuke/pinact/releases/tag/v3.0.0)
0707010000002C000081A4000000000000000000000001693FE2CD0000015F000000000000000000000000000000000000001F00000000pinact-3.6.0/docs/codes/004.md# pinact doesn't support this configuration format version. Maybe you need to update pinact
This error means your pinact doesn't support the schema version of your configuration file `.pinact.yaml`.
Maybe your pinact is old, or the schema version is invalid.
If the schema version is invalid, please fix it.
If your pinact is old, please update it.
0707010000002D000081A4000000000000000000000001693FE2CD0000002A000000000000000000000000000000000000002000000000pinact-3.6.0/docs/old_schema.md# Old Schema
- [Schema v2](schema_v2.md)
0707010000002E000081A4000000000000000000000001693FE2CD0000052D000000000000000000000000000000000000001F00000000pinact-3.6.0/docs/schema_v2.md# Schema v2
> [!WARNING]
> pinact v3 or later doesn't support this schema version.
```yaml
version: 2
```
`pinact <= v2.2.0`
pinact v2.2.0 or older supports this schema version.
## `files`
This is optional.
A list of target files.
## `files[].pattern`
This is required.
A regular expression of target files.
If files are passed via positional command line arguments, the configuration is ignored.
e.g.
```yaml
files:
- pattern: ^\.github/workflows/.*\\.ya?ml$
```
> [!WARNING]
> Regular expressions doesn't necessarily match with action names exactly.
> For instance, `pattern: action\\.yaml` matches with `foo/action\\.yaml`
## `ignore_actions`
This is optional. A list of ignored actions and reusable workflows.
## `ignore_actions[].name`
This is required.
A regular expression of ignored actions and reusable workflows.
```yaml
ignore_actions:
- name: actions/.*
ref: main
```
> [!WARNING]
> Regular expressions doesn't necessarily match with action names exactly.
> For instance, `name: actions/` matches with `actions/checkout`
## `ignore_actions[].ref`
`pinact >= v2.1.0`
This is required.
A regular expression of ignored action versions (branch, tag, or commit hash).
> [!WARNING]
> Regular expressions must match with action names exactly.
> For instance, `ref: main` matches with `main`
0707010000002F000081A4000000000000000000000001693FE2CD000005EE000000000000000000000000000000000000001400000000pinact-3.6.0/go.modmodule github.com/suzuki-shunsuke/pinact/v3
go 1.25.5
require (
github.com/fatih/color v1.18.0
github.com/goccy/go-yaml v1.19.0
github.com/google/go-cmp v0.7.0
github.com/google/go-github/v80 v80.0.0
github.com/hashicorp/go-version v1.8.0
github.com/lmittmann/tint v1.1.2
github.com/spf13/afero v1.15.0
github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0
github.com/suzuki-shunsuke/slog-error v0.2.1
github.com/suzuki-shunsuke/slog-util v0.3.0
github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0
github.com/urfave/cli/v3 v3.6.1
golang.org/x/oauth2 v0.34.0
gopkg.in/yaml.v3 v3.0.1
)
require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
07070100000030000081A4000000000000000000000001693FE2CD00001E9A000000000000000000000000000000000000001400000000pinact-3.6.0/go.sumal.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v80 v80.0.0 h1:BTyk3QOHekrk5VF+jIGz1TNEsmeoQG9K/UWaaP+EWQs=
github.com/google/go-github/v80 v80.0.0/go.mod h1:pRo4AIMdHW83HNMGfNysgSAv0vmu+/pkY8nZO9FT9Yo=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0 h1:g7askc+nskCkKRWTVOdsAT8nMhwiaVT6Dmlnh6uvITM=
github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0/go.mod h1:yFO7h5wwFejxi6jbtazqmk7b/JSBxHcit8DGwb1bhg0=
github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 h1:oVXrrYNGBq4POyITQNWKzwsYz7B2nUcqtDbeX4BfeEc=
github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0/go.mod h1:kDFtLeftDiIUUHXGI3xq5eJ+uAOi50FPrxPENTHktJ0=
github.com/suzuki-shunsuke/slog-error v0.2.1 h1:zcWOEo451RWmgusiONt/GueyvkTL7n4qA0ZJ3gTEjbA=
github.com/suzuki-shunsuke/slog-error v0.2.1/go.mod h1:w45QyO2G0uiEuo9hhrcLqqRl3hmYon9jGgq9CrCxxOY=
github.com/suzuki-shunsuke/slog-util v0.3.0 h1:s+Go2yZqBnJCyV4kj1MDJEITfS7ELdDAEKk/aCulBkQ=
github.com/suzuki-shunsuke/slog-util v0.3.0/go.mod h1:PgZMd+2rC8pA9jBbXDfkI8mTuWYAiaVkKxjrbLtfN5I=
github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 h1:ORT/qQxsKuWwuy2N/z2f2hmbKWmlS346/j4jGhxsxLo=
github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0/go.mod h1:BYtzUgA4oeUVUFoJIONWOquvIUy0cl7DpAeCya3mVJU=
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
07070100000031000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001900000000pinact-3.6.0/json-schema07070100000032000081A4000000000000000000000001693FE2CD00000784000000000000000000000000000000000000002500000000pinact-3.6.0/json-schema/pinact.json{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/suzuki-shunsuke/pinact/v3/pkg/config/config",
"$ref": "#/$defs/Config",
"$defs": {
"Config": {
"properties": {
"version": {
"type": "integer",
"enum": [
2,
3
]
},
"files": {
"items": {
"$ref": "#/$defs/File"
},
"type": "array",
"description": "Target files. If files are passed via positional command line arguments"
},
"ignore_actions": {
"items": {
"$ref": "#/$defs/IgnoreAction"
},
"type": "array",
"description": "Actions and reusable workflows that pinact ignores"
},
"ghes": {
"$ref": "#/$defs/GHES",
"description": "GitHub Enterprise Server configuration"
}
},
"additionalProperties": false,
"type": "object"
},
"File": {
"properties": {
"pattern": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"pattern"
]
},
"GHES": {
"properties": {
"api_url": {
"type": "string",
"description": "API URL of the GHES instance (e.g. https://ghes.example.com)"
},
"fallback": {
"type": "boolean",
"description": "Whether to fallback to github.com when a repository is not found on GHES. Default is false"
}
},
"additionalProperties": false,
"type": "object"
},
"IgnoreAction": {
"properties": {
"name": {
"type": "string"
},
"ref": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"name"
]
}
}
}
07070100000033000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001100000000pinact-3.6.0/pkg07070100000034000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001500000000pinact-3.6.0/pkg/cli07070100000035000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001A00000000pinact-3.6.0/pkg/cli/flag07070100000036000081A4000000000000000000000001693FE2CD0000021C000000000000000000000000000000000000002200000000pinact-3.6.0/pkg/cli/flag/flag.gopackage flag
import "github.com/urfave/cli/v3"
type GlobalFlags struct {
LogLevel string
Config string
}
func (gf *GlobalFlags) Flags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "log-level",
Usage: "log level",
Sources: cli.EnvVars("PINACT_LOG_LEVEL"),
Destination: &gf.LogLevel,
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "configuration file path",
Sources: cli.EnvVars("PINACT_CONFIG"),
Destination: &gf.Config,
},
}
}
07070100000037000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001D00000000pinact-3.6.0/pkg/cli/initcmd07070100000038000081A4000000000000000000000001693FE2CD000009B5000000000000000000000000000000000000002800000000pinact-3.6.0/pkg/cli/initcmd/command.go// Package initcmd implements the 'pinact init' command.
// This package is responsible for generating pinact configuration files (.pinact.yaml)
// with default settings to help users quickly set up pinact in their repositories.
// It creates configuration templates that define target workflow files and
// action ignore patterns for the pinning process.
package initcmd
import (
"context"
"fmt"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/flag"
"github.com/suzuki-shunsuke/pinact/v3/pkg/controller/initcmd"
"github.com/suzuki-shunsuke/slog-util/slogutil"
"github.com/urfave/cli/v3"
)
type Flags struct {
*flag.GlobalFlags
Args []string
FirstArg string
}
// New creates a new init command instance with the provided logger.
// It returns a CLI command that can be registered with the main CLI application.
func New(logger *slogutil.Logger, globalFlags *flag.GlobalFlags) *cli.Command {
r := &runner{}
return r.Command(logger, globalFlags)
}
type runner struct{}
// Command returns the CLI command definition for the init subcommand.
// It defines the command name, usage, description, and action handler.
func (r *runner) Command(logger *slogutil.Logger, globalFlags *flag.GlobalFlags) *cli.Command {
flags := &Flags{GlobalFlags: globalFlags}
return &cli.Command{
Name: "init",
Usage: "Create .pinact.yaml if it doesn't exist",
Description: `Create .pinact.yaml if it doesn't exist
$ pinact init
You can also pass configuration file path.
e.g.
$ pinact init .github/pinact.yaml
`,
Action: func(ctx context.Context, cmd *cli.Command) error {
flags.Args = cmd.Args().Slice()
flags.FirstArg = cmd.Args().First()
return r.action(ctx, logger, flags)
},
}
}
// action handles the execution of the init command.
// It creates a default .pinact.yaml configuration file in the specified location.
// The function sets up the necessary controllers and services, determines the output
// path for the configuration file, and delegates to the controller's Init method.
func (r *runner) action(_ context.Context, logger *slogutil.Logger, flags *Flags) error {
if err := logger.SetLevel(flags.LogLevel); err != nil {
return fmt.Errorf("set log level: %w", err)
}
configFilePath := flags.FirstArg
if configFilePath == "" {
configFilePath = flags.Config
}
if configFilePath == "" {
configFilePath = ".pinact.yaml"
}
ctrl := initcmd.New(afero.NewOsFs())
return ctrl.Init(configFilePath) //nolint:wrapcheck
}
07070100000039000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001D00000000pinact-3.6.0/pkg/cli/migrate0707010000003A000081A4000000000000000000000001693FE2CD000008D3000000000000000000000000000000000000002800000000pinact-3.6.0/pkg/cli/migrate/command.go// Package migrate implements the 'pinact migrate' command.
// This package handles the migration of pinact configuration files between
// different schema versions. It ensures smooth upgrades when pinact introduces
// new configuration formats or features, allowing users to automatically
// update their .pinact.yaml files to the latest schema version.
package migrate
import (
"context"
"fmt"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/flag"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
"github.com/suzuki-shunsuke/pinact/v3/pkg/controller/migrate"
"github.com/suzuki-shunsuke/slog-util/slogutil"
"github.com/urfave/cli/v3"
)
type runner struct{}
// New creates a new migrate command for the CLI.
// It initializes a runner with the provided logger and returns
// the configured CLI command for migrating pinact configuration files.
// Returns a pointer to the configured CLI command.
func New(logger *slogutil.Logger, globalFlags *flag.GlobalFlags) *cli.Command {
r := runner{}
return r.Command(logger, globalFlags)
}
// Command builds and returns the migrate CLI command configuration.
// It defines the command name, usage description, and action handler
// for the migrate subcommand.
//
// Returns a pointer to the configured CLI command.
func (r *runner) Command(logger *slogutil.Logger, globalFlags *flag.GlobalFlags) *cli.Command {
return &cli.Command{
Name: "migrate",
Usage: "Migrate .pinact.yaml",
Description: `Migrate the version of .pinact.yaml
$ pinact migrate
`,
Action: func(_ context.Context, _ *cli.Command) error {
return r.action(logger, globalFlags)
},
}
}
// action executes the migrate command logic.
// It configures logging, creates the filesystem interface and controller,
// then performs the configuration file migration.
// Returns an error if migration fails or logging configuration fails.
func (r *runner) action(logger *slogutil.Logger, flags *flag.GlobalFlags) error {
if err := logger.SetLevel(flags.LogLevel); err != nil {
return fmt.Errorf("set log level: %w", err)
}
fs := afero.NewOsFs()
ctrl := migrate.New(fs, config.NewFinder(fs), &migrate.Param{
ConfigFilePath: flags.Config,
})
return ctrl.Migrate(logger.Logger) //nolint:wrapcheck
}
0707010000003B000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001900000000pinact-3.6.0/pkg/cli/run0707010000003C000081A4000000000000000000000001693FE2CD00001358000000000000000000000000000000000000002400000000pinact-3.6.0/pkg/cli/run/command.go// Package run implements the 'pinact run' command, the core functionality of pinact.
// This package orchestrates the main pinning process for GitHub Actions and reusable workflows,
// including version resolution, SHA pinning, update operations, and pull request review creation.
// It handles various modes of operation (check, diff, fix, update, review) and integrates
// with GitHub Actions CI environment for automated processing. The package also manages
// include/exclude patterns for selective action processing and coordinates with the
// controller layer to perform the actual file modifications.
package run
import (
"context"
"errors"
"fmt"
"os"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/flag"
"github.com/suzuki-shunsuke/pinact/v3/pkg/di"
"github.com/suzuki-shunsuke/slog-util/slogutil"
"github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave"
"github.com/urfave/cli/v3"
)
// New creates a new run command for the CLI.
// It initializes a runner with the provided logger and returns
// the configured CLI command for pinning GitHub Actions versions.
func New(logger *slogutil.Logger, globalFlags *flag.GlobalFlags, env *urfave.Env) *cli.Command {
r := &runner{}
return r.Command(logger, globalFlags, env)
}
type runner struct{}
// Command builds and returns the run CLI command configuration.
// It defines all flags, options, and the action handler for the run subcommand.
// This command handles the core pinning functionality with various modes
// like check, diff, fix, update, and review.
func (r *runner) Command(logger *slogutil.Logger, globalFlags *flag.GlobalFlags, env *urfave.Env) *cli.Command { //nolint:funlen
flags := &di.Flags{GlobalFlags: globalFlags}
return &cli.Command{
Name: "run",
Usage: "Pin GitHub Actions versions",
Description: `If no argument is passed, pinact searches GitHub Actions workflow files from .github/workflows.
$ pinact run
You can also pass workflow file paths as arguments.
e.g.
$ pinact run .github/actions/foo/action.yaml .github/actions/bar/action.yaml
`,
Action: func(ctx context.Context, _ *cli.Command) error {
pwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get the current directory: %w", err)
}
flags.PWD = pwd
di.SetEnv(flags, env.Getenv)
secrets := &di.Secrets{}
secrets.SetFromEnv(env.Getenv)
return di.Run(ctx, logger, flags, secrets)
},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verify",
Aliases: []string{"v"},
Usage: "Verify if pairs of commit SHA and version are correct",
Destination: &flags.Verify,
},
&cli.BoolFlag{
Name: "check",
Usage: "Exit with a non-zero status code if actions are not pinned. If this is true, files aren't updated",
Destination: &flags.Check,
},
&cli.BoolFlag{
Name: "update",
Aliases: []string{"u"},
Usage: "Update actions to latest versions",
Destination: &flags.Update,
},
&cli.BoolFlag{
Name: "review",
Usage: "Create reviews",
Destination: &flags.Review,
},
&cli.BoolFlag{
Name: "fix",
Usage: "Fix code. By default, this is true. If -check or -diff is true, this is false by default",
Destination: &flags.Fix,
Config: cli.BoolConfig{
Count: &flags.FixCount,
},
},
&cli.BoolFlag{
Name: "diff",
Usage: "Output diff. By default, this is false",
Destination: &flags.Diff,
},
&cli.StringFlag{
Name: "repo-owner",
Usage: "GitHub repository owner",
Sources: cli.EnvVars("GITHUB_REPOSITORY_OWNER"),
Destination: &flags.RepoOwner,
},
&cli.StringFlag{
Name: "repo-name",
Usage: "GitHub repository name",
Destination: &flags.RepoName,
},
&cli.StringFlag{
Name: "sha",
Usage: "Commit SHA to be reviewed",
Destination: &flags.SHA,
},
&cli.IntFlag{
Name: "pr",
Usage: "GitHub pull request number",
Destination: &flags.PR,
},
&cli.StringSliceFlag{
Name: "include",
Aliases: []string{"i"},
Usage: "A regular expression to fix actions",
Destination: &flags.Include,
},
&cli.StringSliceFlag{
Name: "exclude",
Aliases: []string{"e"},
Usage: "A regular expression to exclude actions",
Destination: &flags.Exclude,
},
&cli.IntFlag{
Name: "min-age",
Aliases: []string{"m"},
Usage: "Skip versions released within the specified number of days (requires -u)",
Destination: &flags.MinAge,
Sources: cli.EnvVars("PINACT_MIN_AGE"),
Validator: func(i int) error {
if i < 0 {
return errors.New("--min-age must be a non-negative integer")
}
return nil
},
},
},
Arguments: []cli.Argument{
&cli.StringArgs{
Name: "files",
Max: -1,
Destination: &flags.Args,
},
},
}
}
0707010000003D000081A4000000000000000000000001693FE2CD000005D0000000000000000000000000000000000000001F00000000pinact-3.6.0/pkg/cli/runner.go// Package cli provides the command-line interface layer for pinact.
// This package serves as the main entry point for all CLI operations,
// handling command parsing, flag processing, and routing to appropriate subcommands.
// It orchestrates the overall CLI structure using urfave/cli framework and delegates
// actual business logic to controller packages.
package cli
import (
"context"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/flag"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/initcmd"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/migrate"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/run"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/token"
"github.com/suzuki-shunsuke/slog-util/slogutil"
"github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave"
"github.com/urfave/cli/v3"
)
// Run creates and executes the main pinact CLI application.
// It configures the command structure with global flags and subcommands,
// then runs the CLI with the provided arguments.
func Run(ctx context.Context, logger *slogutil.Logger, env *urfave.Env) error {
globalFlags := &flag.GlobalFlags{}
return urfave.Command(env, &cli.Command{ //nolint:wrapcheck
Name: "pinact",
Usage: "Pin GitHub Actions versions. https://github.com/suzuki-shunsuke/pinact",
Flags: globalFlags.Flags(),
Commands: []*cli.Command{
initcmd.New(logger, globalFlags),
run.New(logger, globalFlags, env),
migrate.New(logger, globalFlags),
token.New(logger),
},
}).Run(ctx, env.Args)
}
0707010000003E000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001B00000000pinact-3.6.0/pkg/cli/token0707010000003F000081A4000000000000000000000001693FE2CD00000449000000000000000000000000000000000000002600000000pinact-3.6.0/pkg/cli/token/command.go// Package token implements the 'pinact token' command for secure GitHub token management.
// This package provides functionality to store and retrieve GitHub access tokens
// using the operating system's native credential storage (Windows Credential Manager,
// macOS Keychain, or GNOME Keyring). It offers a secure alternative to environment
// variables for managing authentication credentials, allowing users to persist tokens
// safely across sessions without exposing them in shell configurations.
package token
import (
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
"github.com/suzuki-shunsuke/slog-util/slogutil"
"github.com/suzuki-shunsuke/urfave-cli-v3-util/keyring/ghtoken"
"github.com/urfave/cli/v3"
)
// New creates a new token command for the CLI.
// It initializes a GitHub token management command using the system keyring
// for secure credential storage and retrieval.
// Returns a pointer to the configured CLI command for token operations.
func New(logger *slogutil.Logger) *cli.Command {
return ghtoken.Command(ghtoken.NewActor(logger.Logger, github.KeyService))
}
07070100000040000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001800000000pinact-3.6.0/pkg/config07070100000041000081A4000000000000000000000001693FE2CD000025E7000000000000000000000000000000000000002200000000pinact-3.6.0/pkg/config/config.go// Package config manages pinact configuration files and validation.
// This package is responsible for reading, parsing, and validating .pinact.yaml
// configuration files. It handles multiple schema versions, manages file patterns
// for targeting specific workflow files, and maintains ignore rules for excluding
// certain actions from the pinning process. The package provides interfaces for
// finding and reading configuration files from standard locations, ensuring
// backward compatibility while supporting schema evolution.
package config
import (
"errors"
"fmt"
"path"
"regexp"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/slog-error/slogerr"
"gopkg.in/yaml.v3"
)
type Config struct {
Version int `json:"version,omitempty" jsonschema:"enum=2,enum=3"`
Files []*File `json:"files,omitempty" jsonschema:"description=Target files. If files are passed via positional command line arguments, this is ignored"`
IgnoreActions []*IgnoreAction `json:"ignore_actions,omitempty" yaml:"ignore_actions" jsonschema:"description=Actions and reusable workflows that pinact ignores"`
GHES *GHES `json:"ghes,omitempty" yaml:"ghes" jsonschema:"description=GitHub Enterprise Server configuration"`
}
type GHES struct {
APIURL string `json:"api_url,omitempty" yaml:"api_url" jsonschema:"description=API URL of the GHES instance (e.g. https://ghes.example.com)"`
Fallback bool `json:"fallback,omitempty" yaml:"fallback" jsonschema:"description=Whether to fallback to github.com when a repository is not found on GHES. Default is false"`
}
type File struct {
Pattern string `json:"pattern"`
}
var (
errUnsupportedConfigVersion = errors.New("pinact doesn't support this configuration format version. Maybe you need to update pinact")
errAbandonedConfigVersion = errors.New("this version was abandoned. Please update the schema version")
errEmptyConfigVersion = errors.New("schema version is required")
)
// validateSchemaVersion checks if the provided configuration schema version is supported.
// It validates against the current supported version (3) and provides helpful error
// messages for unsupported, abandoned, or missing versions.
//
// Parameters:
// - v: schema version number to validate
//
// Returns an error if the version is not supported, nil if valid.
func validateSchemaVersion(v int) error {
switch v {
case 0:
return slogerr.With(errEmptyConfigVersion, //nolint:wrapcheck
"docs", "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/002.md",
)
case 2: //nolint:mnd
return slogerr.With(errAbandonedConfigVersion, //nolint:wrapcheck
"docs", "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/003.md",
)
case 3: //nolint:mnd
return nil
default:
return slogerr.With(errUnsupportedConfigVersion, //nolint:wrapcheck
"docs", "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/004.md",
)
}
}
// Init initializes and validates a File configuration.
// It validates the pattern field and ensures it's a valid glob pattern.
//
// Parameters:
// - v: configuration schema version
//
// Returns an error if validation fails.
func (f *File) Init(v int) error {
if f.Pattern == "" {
return errors.New("pattern is required")
}
if err := validateSchemaVersion(v); err != nil {
return err
}
_, err := path.Match(f.Pattern, "a")
if err != nil {
return fmt.Errorf("parse pattern as a glob: %w", err)
}
return nil
}
type IgnoreAction struct {
Name string `json:"name"`
Ref string `json:"ref,omitempty"`
nameRegexp *regexp.Regexp
refRegexp *regexp.Regexp
}
// Init initializes and validates an IgnoreAction configuration.
// It compiles the name and ref patterns as regular expressions.
//
// Parameters:
// - v: configuration schema version
//
// Returns an error if initialization or validation fails.
func (ia *IgnoreAction) Init(v int) error {
if err := ia.initName(); err != nil {
return err
}
if err := ia.initRef(v); err != nil {
return err
}
return nil
}
// Match checks if an action matches the ignore criteria.
// It evaluates both name and ref patterns against the provided values.
//
// Parameters:
// - name: action name to match against
// - ref: action reference to match against
// - version: configuration schema version
//
// Returns true if the action should be ignored, false otherwise, or an error if matching fails.
func (ia *IgnoreAction) Match(name, ref string, version int) (bool, error) {
f, err := ia.matchName(name, version)
if err != nil {
return false, fmt.Errorf("match name: %w", err)
}
if !f {
return false, nil
}
b, err := ia.matchRef(ref, version)
if err != nil {
return false, fmt.Errorf("match ref: %w", err)
}
return b, nil
}
// initName compiles the name pattern as a regular expression.
// It validates that a name pattern is provided and can be compiled.
//
// Returns an error if the name is empty or the regex compilation fails.
func (ia *IgnoreAction) initName() error {
if ia.Name == "" {
return errors.New("name is required")
}
r, err := regexp.Compile(ia.Name)
if err != nil {
return fmt.Errorf("compile name as a regular expression: %w", err)
}
ia.nameRegexp = r
return nil
}
// initRef compiles the ref pattern as a regular expression.
// It validates that a ref pattern is provided and can be compiled.
//
// Parameters:
// - v: configuration schema version
//
// Returns an error if the ref is empty or the regex compilation fails.
func (ia *IgnoreAction) initRef(v int) error {
if err := validateSchemaVersion(v); err != nil {
return err
}
if ia.Ref == "" {
return errors.New("ref is required")
}
r, err := regexp.Compile(ia.Ref)
if err != nil {
return fmt.Errorf("compile ref as a regular expression: %w", err)
}
ia.refRegexp = r
return nil
}
// matchName checks if the provided name matches the compiled name pattern.
// It performs exact string matching using the regular expression.
//
// Parameters:
// - name: action name to match
// - version: configuration schema version
//
// Returns true if the name matches exactly, false otherwise, or an error if validation fails.
func (ia *IgnoreAction) matchName(name string, version int) (bool, error) {
if err := validateSchemaVersion(version); err != nil {
return false, err
}
return ia.nameRegexp.FindString(name) == name, nil
}
// matchRef checks if the provided ref matches the compiled ref pattern.
// It performs exact string matching using the regular expression.
//
// Parameters:
// - ref: action reference to match
// - version: configuration schema version
//
// Returns true if the ref matches exactly, false otherwise, or an error if validation fails.
func (ia *IgnoreAction) matchRef(ref string, version int) (bool, error) {
if err := validateSchemaVersion(version); err != nil {
return false, err
}
return ia.refRegexp.FindString(ref) == ref, nil
}
// IsEnabled checks if GHES is enabled.
// GHES is enabled if the APIURL is set.
func (g *GHES) IsEnabled() bool {
return g != nil && g.APIURL != ""
}
func (g *GHES) Validate() error {
if g == nil {
return nil
}
if g.APIURL == "" {
return errors.New("GHES api_url is required")
}
return nil
}
// getConfigPath searches for a pinact configuration file in standard locations.
// It checks for .pinact.yaml, .github/pinact.yaml, .pinact.yml, and .github/pinact.yml
// in order of preference.
// Returns the path to the first found configuration file, empty string if none found, or an error.
func getConfigPath(fs afero.Fs) (string, error) {
for _, path := range []string{".pinact.yaml", ".github/pinact.yaml", ".pinact.yml", ".github/pinact.yml"} {
f, err := afero.Exists(fs, path)
if err != nil {
return "", fmt.Errorf("check if %s exists: %w", path, err)
}
if f {
return path, nil
}
}
return "", nil
}
type Finder struct {
fs afero.Fs
}
// NewFinder creates a new configuration file finder.
func NewFinder(fs afero.Fs) *Finder {
return &Finder{fs: fs}
}
// Find locates the configuration file path to use.
// If a specific path is provided, it returns that path.
// Otherwise, it searches for configuration files in standard locations.
func (f *Finder) Find(configFilePath string) (string, error) {
if configFilePath != "" {
return configFilePath, nil
}
p, err := getConfigPath(f.fs)
if err != nil {
return "", err
}
return p, nil
}
type Reader struct {
fs afero.Fs
}
// NewReader creates a new configuration file reader.
func NewReader(fs afero.Fs) *Reader {
return &Reader{fs: fs}
}
// Read loads and parses a configuration file.
// It reads the YAML file, validates the schema version, and initializes
// all configuration components including files and ignore actions.
func (r *Reader) Read(cfg *Config, configFilePath string) error {
if configFilePath == "" {
return nil
}
f, err := r.fs.Open(configFilePath)
if err != nil {
return fmt.Errorf("open a configuration file: %w", err)
}
defer f.Close()
if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
return fmt.Errorf("decode a configuration file as YAML: %w", err)
}
return cfg.Init()
}
// Init initializes and validates the configuration.
// It validates the schema version and initializes all configuration components.
func (cfg *Config) Init() error {
if err := validateSchemaVersion(cfg.Version); err != nil {
return err
}
for _, file := range cfg.Files {
if err := file.Init(cfg.Version); err != nil {
return fmt.Errorf("initialize file: %w", err)
}
}
for _, ia := range cfg.IgnoreActions {
if err := ia.Init(cfg.Version); err != nil {
return fmt.Errorf("initialize ignore_action: %w", err)
}
}
return nil
}
07070100000042000081A4000000000000000000000001693FE2CD00001F03000000000000000000000000000000000000003000000000pinact-3.6.0/pkg/config/config_internal_test.gopackage config
import (
"testing"
"github.com/spf13/afero"
)
func Test_validateSchemaVersion(t *testing.T) {
t.Parallel()
data := []struct {
name string
version int
wantErr bool
}{
{name: "version 0 - empty", version: 0, wantErr: true},
{name: "version 2 - abandoned", version: 2, wantErr: true},
{name: "version 3 - valid", version: 3, wantErr: false},
{name: "version 4 - unsupported", version: 4, wantErr: true},
{name: "version 99 - unsupported", version: 99, wantErr: true},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
err := validateSchemaVersion(d.version)
if d.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !d.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestFile_Init(t *testing.T) {
t.Parallel()
data := []struct {
name string
file *File
version int
wantErr bool
}{
{name: "valid pattern", file: &File{Pattern: "*.yaml"}, version: 3, wantErr: false},
{name: "empty pattern", file: &File{Pattern: ""}, version: 3, wantErr: true},
{name: "invalid version", file: &File{Pattern: "*.yaml"}, version: 0, wantErr: true},
{name: "invalid glob pattern", file: &File{Pattern: "[invalid"}, version: 3, wantErr: true},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
err := d.file.Init(d.version)
if d.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !d.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestIgnoreAction_Init(t *testing.T) {
t.Parallel()
data := []struct {
name string
ia *IgnoreAction
version int
wantErr bool
}{
{name: "valid", ia: &IgnoreAction{Name: "actions/checkout", Ref: "v4"}, version: 3, wantErr: false},
{name: "empty name", ia: &IgnoreAction{Name: "", Ref: "v4"}, version: 3, wantErr: true},
{name: "empty ref", ia: &IgnoreAction{Name: "actions/checkout", Ref: ""}, version: 3, wantErr: true},
{name: "invalid name regex", ia: &IgnoreAction{Name: "[invalid", Ref: "v4"}, version: 3, wantErr: true},
{name: "invalid ref regex", ia: &IgnoreAction{Name: "actions/checkout", Ref: "[invalid"}, version: 3, wantErr: true},
{name: "invalid version", ia: &IgnoreAction{Name: "actions/checkout", Ref: "v4"}, version: 0, wantErr: true},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
err := d.ia.Init(d.version)
if d.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !d.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestGHES_IsEnabled(t *testing.T) {
t.Parallel()
data := []struct {
name string
ghes *GHES
exp bool
}{
{name: "nil", ghes: nil, exp: false},
{name: "empty api url", ghes: &GHES{APIURL: ""}, exp: false},
{name: "with api url", ghes: &GHES{APIURL: "https://ghes.example.com"}, exp: true},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
if got := d.ghes.IsEnabled(); got != d.exp {
t.Errorf("wanted %v, got %v", d.exp, got)
}
})
}
}
func TestGHES_Validate(t *testing.T) {
t.Parallel()
data := []struct {
name string
ghes *GHES
wantErr bool
}{
{name: "nil", ghes: nil, wantErr: false},
{name: "empty api url", ghes: &GHES{APIURL: ""}, wantErr: true},
{name: "with api url", ghes: &GHES{APIURL: "https://ghes.example.com"}, wantErr: false},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
err := d.ghes.Validate()
if d.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !d.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestFinder_Find(t *testing.T) {
t.Parallel()
t.Run("explicit path", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
finder := NewFinder(fs)
got, err := finder.Find("/custom/path.yaml")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "/custom/path.yaml" {
t.Errorf("wanted %q, got %q", "/custom/path.yaml", got)
}
})
t.Run("search default paths", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
if err := afero.WriteFile(fs, ".github/pinact.yaml", []byte(""), 0o644); err != nil {
t.Fatal(err)
}
finder := NewFinder(fs)
got, err := finder.Find("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != ".github/pinact.yaml" {
t.Errorf("wanted %q, got %q", ".github/pinact.yaml", got)
}
})
}
func TestReader_Read(t *testing.T) {
t.Parallel()
t.Run("empty path", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
reader := NewReader(fs)
cfg := &Config{}
if err := reader.Read(cfg, ""); err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("valid config", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
content := `version: 3
files:
- pattern: "*.yaml"
`
if err := afero.WriteFile(fs, ".pinact.yaml", []byte(content), 0o644); err != nil {
t.Fatal(err)
}
reader := NewReader(fs)
cfg := &Config{}
if err := reader.Read(cfg, ".pinact.yaml"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Version != 3 {
t.Errorf("Version: wanted 3, got %d", cfg.Version)
}
if len(cfg.Files) != 1 {
t.Errorf("Files length: wanted 1, got %d", len(cfg.Files))
}
})
t.Run("file not found", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
reader := NewReader(fs)
cfg := &Config{}
if err := reader.Read(cfg, "nonexistent.yaml"); err == nil {
t.Error("expected error, got nil")
}
})
t.Run("invalid yaml", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
if err := afero.WriteFile(fs, ".pinact.yaml", []byte("invalid: yaml: content:"), 0o644); err != nil {
t.Fatal(err)
}
reader := NewReader(fs)
cfg := &Config{}
if err := reader.Read(cfg, ".pinact.yaml"); err == nil {
t.Error("expected error, got nil")
}
})
}
func TestConfig_Init(t *testing.T) {
t.Parallel()
t.Run("valid config", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Version: 3,
Files: []*File{{Pattern: "*.yaml"}},
IgnoreActions: []*IgnoreAction{
{Name: "actions/checkout", Ref: "v4"},
},
}
if err := cfg.Init(); err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("invalid version", func(t *testing.T) {
t.Parallel()
cfg := &Config{Version: 0}
if err := cfg.Init(); err == nil {
t.Error("expected error, got nil")
}
})
t.Run("invalid file", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Version: 3,
Files: []*File{{Pattern: ""}},
}
if err := cfg.Init(); err == nil {
t.Error("expected error, got nil")
}
})
t.Run("invalid ignore action", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Version: 3,
IgnoreActions: []*IgnoreAction{{Name: "", Ref: "v4"}},
}
if err := cfg.Init(); err == nil {
t.Error("expected error, got nil")
}
})
}
func Test_getConfigPath(t *testing.T) {
t.Parallel()
data := []struct {
name string
paths []string
exp string
}{
{
name: "no config",
paths: []string{},
exp: "",
},
{
name: "primary",
paths: []string{".pinact.yaml"},
exp: ".pinact.yaml",
},
{
name: "another",
paths: []string{".github/pinact.yaml"},
exp: ".github/pinact.yaml",
},
{
name: "both primary and others",
paths: []string{".pinact.yaml", ".github/pinact.yaml"},
exp: ".pinact.yaml",
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
for _, path := range d.paths {
if err := afero.WriteFile(fs, path, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := getConfigPath(fs)
if err != nil {
t.Fatal(err)
}
if got != d.exp {
t.Fatalf(`wanted %s, got %s`, d.exp, got)
}
})
}
}
07070100000043000081A4000000000000000000000001693FE2CD000005E5000000000000000000000000000000000000002700000000pinact-3.6.0/pkg/config/config_test.gopackage config_test
import (
"testing"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
)
func TestIgnoreAction_Match(t *testing.T) {
t.Parallel()
data := []struct {
name string
ignoreAction *config.IgnoreAction
actionName string
actionRef string
configVersion int
expected bool
}{
{
name: "match by name and ref (v3)",
ignoreAction: &config.IgnoreAction{
Name: "actions/checkout",
Ref: "main",
},
actionName: "actions/checkout",
actionRef: "main",
expected: true,
configVersion: 3,
},
{
name: "not match (v3)",
ignoreAction: &config.IgnoreAction{
Name: "actions/checkout",
Ref: "main",
},
actionName: "actions/checkout",
actionRef: "main-malicious",
expected: false,
configVersion: 3,
},
{
name: "not match name (v3)",
ignoreAction: &config.IgnoreAction{
Name: "actions/",
Ref: "main",
},
actionName: "actions/checkout",
actionRef: "main",
expected: false,
configVersion: 3,
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
if err := d.ignoreAction.Init(d.configVersion); err != nil {
t.Fatalf("failed to initialize ignore action: %v", err)
}
got, err := d.ignoreAction.Match(d.actionName, d.actionRef, d.configVersion)
if err != nil {
t.Fatalf("failed to match: %v", err)
}
if got != d.expected {
t.Fatalf("wanted %v, got %v", d.expected, got)
}
})
}
}
07070100000044000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001C00000000pinact-3.6.0/pkg/controller07070100000045000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002400000000pinact-3.6.0/pkg/controller/initcmd07070100000046000081A4000000000000000000000001693FE2CD0000009D000000000000000000000000000000000000003200000000pinact-3.6.0/pkg/controller/initcmd/controller.gopackage initcmd
import "github.com/spf13/afero"
type Controller struct {
fs afero.Fs
}
func New(fs afero.Fs) *Controller {
return &Controller{fs: fs}
}
07070100000047000081A4000000000000000000000001693FE2CD0000056A000000000000000000000000000000000000002C00000000pinact-3.6.0/pkg/controller/initcmd/init.gopackage initcmd
import (
"fmt"
"os"
"github.com/spf13/afero"
)
const (
templateConfig = `# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json
# pinact - https://github.com/suzuki-shunsuke/pinact
version: 3
# files:
# - pattern: action.yaml
# - pattern: */action.yaml
ignore_actions:
# - name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml
# ref: v\d+\.\d+\.\d+
# - name: actions/.*
# ref: main
# - name: suzuki-shunsuke/.*
# ref: release-.*
`
filePermission os.FileMode = 0o644
)
// Init creates a new pinact configuration file if it doesn't exist.
// It checks if the configuration file already exists and creates it with
// a template configuration if it doesn't exist.
//
// Parameters:
// - configFilePath: path where the configuration file should be created
//
// Returns an error if file operations fail, nil if successful or file already exists.
func (c *Controller) Init(configFilePath string) error {
f, err := afero.Exists(c.fs, configFilePath)
if err != nil {
return fmt.Errorf("check if a configuration file exists: %w", err)
}
if f {
return nil
}
if err := afero.WriteFile(c.fs, configFilePath, []byte(templateConfig), filePermission); err != nil {
return fmt.Errorf("create a configuration file: %w", err)
}
return nil
}
07070100000048000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002400000000pinact-3.6.0/pkg/controller/migrate07070100000049000081A4000000000000000000000001693FE2CD000010BD000000000000000000000000000000000000002B00000000pinact-3.6.0/pkg/controller/migrate/ast.gopackage migrate
import (
"errors"
"fmt"
"log/slog"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
)
// parseConfigAST parses and migrates a YAML configuration file using AST.
// It parses the YAML content, applies migrations to each document,
// and returns the updated YAML content as a string.
//
// Parameters:
// - _: slog logger (unused in current implementation)
// - content: YAML configuration file content as bytes
//
// Returns the migrated YAML content as string and any error encountered.
func parseConfigAST(_ *slog.Logger, content []byte) (string, error) {
file, err := parser.ParseBytes(content, parser.ParseComments)
if err != nil {
return "", fmt.Errorf("parse a workflow file as YAML: %w", err)
}
for _, doc := range file.Docs {
if err := parseDocAST(doc); err != nil {
return "", err
}
}
return file.String(), nil
}
// parseDocAST migrates a single YAML document node.
// It processes the document body to migrate ignore_actions and version fields
// to the latest configuration schema format.
//
// Parameters:
// - doc: YAML document node to migrate
//
// Returns an error if migration fails.
func parseDocAST(doc *ast.DocumentNode) error {
body, ok := doc.Body.(*ast.MappingNode)
if !ok {
return errors.New("document body must be *ast.MappingNode")
}
if err := migrateIgnoreActions(body); err != nil {
return fmt.Errorf("migrate ignore_actions: %w", err)
}
if err := migrateVersion(body); err != nil {
return fmt.Errorf("migrate version: %w", err)
}
return nil
}
// migrateIgnoreActions migrates the ignore_actions section of the configuration.
// It processes each ignore action to ensure they have required ref fields
// with default values if missing.
//
// Parameters:
// - body: YAML mapping node containing the configuration
//
// Returns an error if migration fails.
func migrateIgnoreActions(body *ast.MappingNode) error {
// ignore_actions:
// - name:
// ref:
ignoreActionsNode := findNodeByKey(body.Values, "ignore_actions")
if ignoreActionsNode == nil {
return nil
}
switch seq := ignoreActionsNode.Value.(type) {
case *ast.SequenceNode:
for _, value := range seq.Values {
if err := migrateIgnoreAction(value); err != nil {
return fmt.Errorf("migrate ignore_actions: %w", err)
}
}
return nil
default:
return errors.New("ignore_actions must be an array")
}
}
// migrateIgnoreAction migrates a single ignore action configuration.
// It ensures the ignore action has a ref field, adding a default ".*"
// pattern if the ref field is missing.
//
// Parameters:
// - body: YAML node representing an ignore action
//
// Returns an error if migration fails.
func migrateIgnoreAction(body ast.Node) error {
// name:
// ref:
m, ok := body.(*ast.MappingNode)
if !ok {
return errors.New("ignore_action must be a mapping node")
}
if refNode := findNodeByKey(m.Values, "ref"); refNode != nil {
return nil
}
node, err := yaml.ValueToNode(map[string]any{
"ref": ".*",
})
if err != nil {
return fmt.Errorf("convert ref to node: %w", err)
}
m.Merge(node.(*ast.MappingNode)) //nolint:forcetypeassert
return nil
}
// migrateVersion migrates the version field of the configuration.
// It adds a version field if missing or updates an existing version
// to the current schema version (3).
//
// Parameters:
// - body: YAML mapping node containing the configuration
//
// Returns an error if migration fails.
func migrateVersion(body *ast.MappingNode) error {
// version:
versionNode := findNodeByKey(body.Values, "version")
if versionNode == nil {
node, err := yaml.ValueToNode(map[string]any{
"version": 3, //nolint:mnd
})
if err != nil {
return fmt.Errorf("convert version to node: %w", err)
}
body.Merge(node.(*ast.MappingNode)) //nolint:forcetypeassert
return nil
}
switch v := versionNode.Value.(type) {
case *ast.IntegerNode:
v.Token.Value = "3"
v.Value = 3
return nil
default:
return errors.New("version must be a number")
}
}
func findNodeByKey(values []*ast.MappingValueNode, key string) *ast.MappingValueNode {
for _, value := range values {
k, ok := value.Key.(*ast.StringNode)
if !ok {
continue
}
if k.Value == key {
return value
}
}
return nil
}
0707010000004A000081A4000000000000000000000001693FE2CD00000420000000000000000000000000000000000000003200000000pinact-3.6.0/pkg/controller/migrate/controller.go// Package migrate handles configuration file migration between schema versions.
// This package provides the business logic for upgrading pinact configuration files
// when new schema versions are introduced. It ensures smooth transitions between
// different configuration formats, preserving user settings while adapting to new
// features and structural changes. The migration process maintains backward
// compatibility and helps users keep their configurations up-to-date with the
// latest pinact capabilities.
package migrate
import (
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
)
type Controller struct {
fs afero.Fs
cfg *config.Config
param *Param
cfgFinder ConfigFinder
}
type ConfigFinder interface {
Find(configFilePath string) (string, error)
}
type Param struct {
ConfigFilePath string
}
func New(fs afero.Fs, cfgFinder ConfigFinder, param *Param) *Controller {
return &Controller{
param: param,
fs: fs,
cfg: &config.Config{},
cfgFinder: cfgFinder,
}
}
0707010000004B000081A4000000000000000000000001693FE2CD00000BFA000000000000000000000000000000000000002F00000000pinact-3.6.0/pkg/controller/migrate/migrate.gopackage migrate
import (
"fmt"
"log/slog"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
"gopkg.in/yaml.v3"
)
// Migrate performs configuration file migration to the latest schema version.
// It finds the configuration file, reads and parses it, determines the required
// migration path, and applies necessary transformations to update the schema.
func (c *Controller) Migrate(logger *slog.Logger) error {
// find and read .pinact.yaml
p, err := c.cfgFinder.Find(c.param.ConfigFilePath)
if err != nil {
return fmt.Errorf("find a configurationfile: %w", err)
}
if p == "" {
// if .pinact.yaml doesn't exist, return nil
logger.Warn("no configuration file is found")
return nil
}
c.param.ConfigFilePath = p
content, err := afero.ReadFile(c.fs, p)
if err != nil {
return fmt.Errorf("read a file: %w", err)
}
cfg := &config.Config{}
if err := yaml.Unmarshal(content, cfg); err != nil {
return fmt.Errorf("parse a config file: %w", err)
}
c.cfg = cfg
s, err := c.migrate(logger, content)
if err != nil {
return err
}
if s == "" {
logger.Info("configuration file isn't changed")
return nil
}
if err := c.edit(c.param.ConfigFilePath, s); err != nil {
return fmt.Errorf("edit the configuration file: %w", err)
}
return nil
}
// edit writes the migrated configuration content back to the file.
// It preserves the original file permissions while updating the content
// with the migrated configuration.
func (c *Controller) edit(file, content string) error {
stat, err := c.fs.Stat(file)
if err != nil {
return fmt.Errorf("get configuration file stat: %w", err)
}
if err := afero.WriteFile(c.fs, file, []byte(content), stat.Mode()); err != nil {
return fmt.Errorf("write the configuration file: %w", err)
}
return nil
}
// migrate determines and applies the appropriate migration strategy.
// It examines the current configuration version and routes to the
// corresponding migration function.
func (c *Controller) migrate(logger *slog.Logger, content []byte) (string, error) {
switch c.cfg.Version {
case 2: //nolint:mnd
return c.migrateV2(logger, content)
case 3: //nolint:mnd
return "", nil
case 0:
return c.migrateEmptyVersion(logger, content)
default:
return "", fmt.Errorf("unsupported version: %d", c.cfg.Version)
}
}
// migrateEmptyVersion migrates configuration files without version information.
// It handles legacy configuration files that don't have explicit version
// fields by applying AST-based migration.
func (c *Controller) migrateEmptyVersion(logger *slog.Logger, content []byte) (string, error) {
return parseConfigAST(logger, content)
}
// migrateV2 migrates configuration files from version 2 to version 3.
// It applies necessary transformations to update the schema from version 2
// format to the current version 3 format.
func (c *Controller) migrateV2(logger *slog.Logger, content []byte) (string, error) {
// Add code comment
// Change version from 2 to 3
// Set name_format and ref_format
return parseConfigAST(logger, content)
}
0707010000004C000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002000000000pinact-3.6.0/pkg/controller/run0707010000004D000081A4000000000000000000000001693FE2CD000008DC000000000000000000000000000000000000002E00000000pinact-3.6.0/pkg/controller/run/controller.go// Package run implements the core business logic for pinning GitHub Actions.
// This package contains the main controller that orchestrates the entire pinning process,
// including parsing workflow files, resolving action versions through GitHub API,
// converting mutable tags to immutable commit SHAs, and applying updates.
// It handles various operation modes (check, diff, fix, update), manages caching
// for API efficiency, and supports creating pull request reviews. The package
// provides a clean separation between the CLI layer and the actual file processing
// logic, coordinating with GitHub services and filesystem operations.
package run
import (
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
)
type Controller struct {
repositoriesService RepositoriesService
pullRequestsService github.PullRequestsService
gitService GitService
fs afero.Fs
cfg *config.Config
param *ParamRun
logger *Logger
}
// New creates a new Controller for running pinact operations.
// It initializes the controller with all necessary dependencies for processing
// GitHub Actions workflow files, including GitHub API services, filesystem
// interface, configuration management, and operation parameters.
//
// Parameters:
// - repositoriesService: GitHub API service for repository operations
// - pullRequestsService: GitHub API service for pull request operations
// - gitService: GitHub API service for git operations (optional, for cooldown feature)
// - fs: filesystem interface for file operations
// - cfg: configuration settings
// - param: operation parameters and settings
//
// Returns a pointer to the configured Controller.
func New(repositoriesService RepositoriesService, pullRequestsService github.PullRequestsService, gitService GitService, fs afero.Fs, cfg *config.Config, param *ParamRun) *Controller {
return &Controller{
repositoriesService: repositoriesService,
pullRequestsService: pullRequestsService,
gitService: gitService,
param: param,
fs: fs,
cfg: cfg,
logger: NewLogger(param.Stderr),
}
}
0707010000004E000081A4000000000000000000000001693FE2CD00001D01000000000000000000000000000000000000002A00000000pinact-3.6.0/pkg/controller/run/github.gopackage run
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/hashicorp/go-version"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
"github.com/suzuki-shunsuke/slog-error/slogerr"
)
// RepositoriesService defines the interface for GitHub Repositories API operations
// used by the Controller.
type RepositoriesService interface {
ListTags(ctx context.Context, logger *slog.Logger, owner, repo string, opts *github.ListOptions) ([]*github.RepositoryTag, *github.Response, error)
ListReleases(ctx context.Context, logger *slog.Logger, owner, repo string, opts *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error)
GetCommitSHA1(ctx context.Context, logger *slog.Logger, owner, repo, ref, lastSHA string) (string, *github.Response, error)
}
// GitService defines the interface for GitHub Git API operations
// used by the Controller.
type GitService interface {
GetCommit(ctx context.Context, logger *slog.Logger, owner, repo, sha string) (*github.Commit, *github.Response, error)
}
// getLatestVersion determines the latest version of a repository.
// It first tries to get the latest version from releases, and if that fails
// or returns empty, it falls back to getting the latest version from tags.
func (c *Controller) getLatestVersion(ctx context.Context, logger *slog.Logger, owner, repo, currentVersion string) (string, error) {
isStable := isStableVersion(currentVersion)
// Calculate cutoff once for min-age filtering
var cutoff time.Time
if c.param.MinAge > 0 {
cutoff = c.param.Now.AddDate(0, 0, -c.param.MinAge)
}
lv, err := c.getLatestVersionFromReleases(ctx, logger, owner, repo, isStable, cutoff)
if err != nil {
slogerr.WithError(logger, err).Debug("get the latest version from releases")
}
if lv != "" {
return lv, nil
}
return c.getLatestVersionFromTags(ctx, logger, owner, repo, isStable, cutoff)
}
func isStableVersion(v string) bool {
if v == "" {
return false
}
cv, err := version.NewVersion(v)
return err == nil && cv.Prerelease() == ""
}
// compare evaluates a tag against the current latest version.
// It attempts to parse the tag as semantic version and compares it.
// If parsing fails, it falls back to string comparison.
func compare(latestSemver *version.Version, latestVersion, tag string) (*version.Version, string, error) {
v, err := version.NewVersion(tag)
if err != nil {
if tag > latestVersion {
latestVersion = tag
}
return latestSemver, latestVersion, fmt.Errorf("parse a tag as a semver: %w", err)
}
if latestSemver != nil {
if v.GreaterThan(latestSemver) {
return v, "", nil
}
return latestSemver, "", nil
}
return v, "", nil
}
// getLatestVersionFromReleases finds the latest version from repository releases.
// It retrieves releases from GitHub API and compares them to find the highest
// version using semantic versioning when possible, falling back to string comparison.
func (c *Controller) getLatestVersionFromReleases(ctx context.Context, logger *slog.Logger, owner, repo string, isStable bool, cutoff time.Time) (string, error) {
opts := &github.ListOptions{
PerPage: 30, //nolint:mnd
}
releases, _, err := c.repositoriesService.ListReleases(ctx, logger, owner, repo, opts)
if err != nil {
return "", fmt.Errorf("list releases: %w", err)
}
var latestSemver *version.Version
latestVersion := ""
for _, release := range releases {
// Skip prereleases if current version is stable (issue #1095)
if isStable && release.GetPrerelease() {
continue
}
tag := release.GetTagName()
// Skip releases within cooldown period
if !cutoff.IsZero() && release.GetPublishedAt().After(cutoff) {
logger.Info("skip release due to cooldown",
"tag", tag,
"published_at", release.GetPublishedAt())
continue
}
ls, lv, err := compare(latestSemver, latestVersion, tag)
latestSemver = ls
latestVersion = lv
if err != nil {
slogerr.WithError(logger, err).Debug("compare tags", "tag", tag)
continue
}
}
if latestSemver != nil {
return latestSemver.Original(), nil
}
return latestVersion, nil
}
// checkTagCooldown checks if a tag should be skipped due to cooldown period.
// It returns true if the tag should be skipped.
func checkTagCooldown(ctx context.Context, logger *slog.Logger, gitService GitService, owner, repo, tagName, sha string, cutoff time.Time) bool {
if cutoff.IsZero() || gitService == nil || sha == "" {
return false
}
commit, _, err := gitService.GetCommit(ctx, logger, owner, repo, sha)
if err != nil {
slogerr.WithError(logger, err).Warn("skip tag: failed to get commit for cooldown check", "tag", tagName, "sha", sha)
return true
}
if commit.GetCommitter().GetDate().After(cutoff) {
logger.Info("skip tag due to cooldown",
"tag", tagName,
"committed_at", commit.GetCommitter().GetDate())
return true
}
return false
}
// getLatestVersionFromTags finds the latest version from repository tags.
// It retrieves tags from GitHub API and compares them to find the highest
// version using semantic versioning when possible, falling back to string comparison.
// It filters out prerelease versions when currentVersion is stable.
func (c *Controller) getLatestVersionFromTags(ctx context.Context, logger *slog.Logger, owner, repo string, isStable bool, cutoff time.Time) (string, error) {
opts := &github.ListOptions{
PerPage: 30, //nolint:mnd
}
tags, _, err := c.repositoriesService.ListTags(ctx, logger, owner, repo, opts)
if err != nil {
return "", fmt.Errorf("list tags: %w", err)
}
var latestSemver *version.Version
latestVersion := ""
for _, tag := range tags {
t := tag.GetName()
// Skip prereleases if current version is stable (issue #1095)
if isStable {
if tv, err := version.NewVersion(t); err == nil && tv.Prerelease() != "" {
continue
}
}
// Skip tags within cooldown period
if checkTagCooldown(ctx, logger, c.gitService, owner, repo, t, tag.GetCommit().GetSHA(), cutoff) {
continue
}
ls, lv, err := compare(latestSemver, latestVersion, t)
latestSemver = ls
latestVersion = lv
if err != nil {
slogerr.WithError(logger, err).Debug("compare tags", "tag", tag)
continue
}
}
if latestSemver != nil {
return latestSemver.Original(), nil
}
return latestVersion, nil
}
// review creates a pull request review comment.
// It constructs a comment with either a suggestion or error message and
// posts it to the specified pull request using the GitHub API.
func (c *Controller) review(ctx context.Context, filePath, sha string, line int, suggestion string, err error) (int, error) {
cmt := &github.PullRequestComment{
Body: github.Ptr(""),
Path: github.Ptr(filePath),
Line: github.Ptr(line),
}
if sha != "" {
cmt.CommitID = github.Ptr(sha)
}
const header = "Reviewed by [pinact](https://github.com/suzuki-shunsuke/pinact)"
switch {
case suggestion != "":
cmt.Body = github.Ptr(fmt.Sprintf("%s\n```suggestion\n%s\n```", header, suggestion))
case err != nil:
cmt.Body = github.Ptr(fmt.Sprintf("%s\n%s", header, err.Error()))
default:
return 0, errors.New("either suggestion or error must be provided")
}
_, resp, e := c.pullRequestsService.CreateComment(ctx, c.param.Review.RepoOwner, c.param.Review.RepoName, c.param.Review.PullRequest, cmt)
code := 0
if resp != nil {
code = resp.StatusCode
}
if e != nil {
return code, fmt.Errorf("create a review comment: %w", e)
}
return code, nil
}
0707010000004F000081A4000000000000000000000001693FE2CD00004632000000000000000000000000000000000000003800000000pinact-3.6.0/pkg/controller/run/github_internal_test.gopackage run
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"github.com/hashicorp/go-version"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
)
func Test_compare(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
latestSemver *version.Version
latestVersion string
tag string
wantSemver string
wantLatestVersion string
wantErr bool
}{
{
name: "new semver is greater than current semver",
latestSemver: version.Must(version.NewVersion("1.0.0")),
latestVersion: "",
tag: "2.0.0",
wantSemver: "2.0.0",
wantLatestVersion: "",
wantErr: false,
},
{
name: "new semver is less than current semver",
latestSemver: version.Must(version.NewVersion("2.0.0")),
latestVersion: "",
tag: "1.0.0",
wantSemver: "2.0.0",
wantLatestVersion: "",
wantErr: false,
},
{
name: "new semver equals current semver",
latestSemver: version.Must(version.NewVersion("1.0.0")),
latestVersion: "",
tag: "1.0.0",
wantSemver: "1.0.0",
wantLatestVersion: "",
wantErr: false,
},
{
name: "first semver with nil latest",
latestSemver: nil,
latestVersion: "",
tag: "1.2.3",
wantSemver: "1.2.3",
wantLatestVersion: "",
wantErr: false,
},
{
name: "semver with v prefix",
latestSemver: nil,
latestVersion: "",
tag: "v1.2.3",
wantSemver: "v1.2.3",
wantLatestVersion: "",
wantErr: false,
},
{
name: "invalid semver with greater string comparison",
latestSemver: nil,
latestVersion: "main",
tag: "release",
wantSemver: "",
wantLatestVersion: "release",
wantErr: true,
},
{
name: "invalid semver with lesser string comparison",
latestSemver: nil,
latestVersion: "release",
tag: "main",
wantSemver: "",
wantLatestVersion: "release",
wantErr: true,
},
{
name: "invalid semver as first tag",
latestSemver: nil,
latestVersion: "",
tag: "not-a-version",
wantSemver: "",
wantLatestVersion: "not-a-version",
wantErr: true,
},
{
name: "invalid tag with existing semver",
latestSemver: version.Must(version.NewVersion("1.0.0")),
latestVersion: "",
tag: "invalid",
wantSemver: "1.0.0",
wantLatestVersion: "invalid",
wantErr: true,
},
{
name: "compare with prerelease versions",
latestSemver: version.Must(version.NewVersion("1.0.0-alpha")),
latestVersion: "",
tag: "1.0.0",
wantSemver: "1.0.0",
wantLatestVersion: "",
wantErr: false,
},
{
name: "compare with build metadata",
latestSemver: version.Must(version.NewVersion("1.0.0+build.1")),
latestVersion: "",
tag: "1.0.0+build.2",
wantSemver: "1.0.0+build.1",
wantLatestVersion: "",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotSemver, gotLatestVersion, err := compare(tt.latestSemver, tt.latestVersion, tt.tag)
if (err != nil) != tt.wantErr {
t.Errorf("compare() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Check semver result
if tt.wantSemver == "" {
if gotSemver != nil {
t.Errorf("compare() gotSemver = %v, want nil", gotSemver)
}
} else {
if gotSemver == nil {
t.Errorf("compare() gotSemver = nil, want %v", tt.wantSemver)
} else if gotSemver.Original() != tt.wantSemver {
t.Errorf("compare() gotSemver = %v, want %v", gotSemver.Original(), tt.wantSemver)
}
}
// Check latest version string result
if gotLatestVersion != tt.wantLatestVersion {
t.Errorf("compare() gotLatestVersion = %v, want %v", gotLatestVersion, tt.wantLatestVersion)
}
})
}
}
// mockRepoService is a mock implementation of RepositoriesService for testing the underlying service
type mockRepoService struct {
listReleasesFunc func(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error)
listTagsFunc func(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.RepositoryTag, *github.Response, error)
}
func (m *mockRepoService) ListTags(ctx context.Context, owner string, repo string, opts *github.ListOptions) ([]*github.RepositoryTag, *github.Response, error) {
if m.listTagsFunc != nil {
return m.listTagsFunc(ctx, owner, repo, opts)
}
return nil, nil, errors.New("not implemented")
}
func (m *mockRepoService) GetCommitSHA1(_ context.Context, _, _, _, _ string) (string, *github.Response, error) {
return "", nil, errors.New("not implemented")
}
func (m *mockRepoService) ListReleases(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error) {
if m.listReleasesFunc != nil {
return m.listReleasesFunc(ctx, owner, repo, opts)
}
return nil, nil, errors.New("not implemented")
}
func (m *mockRepoService) Get(_ context.Context, _, _ string) (*github.Repository, *github.Response, error) {
return nil, nil, nil
}
// newTestRepoService creates a RepositoriesServiceImpl with the given mock for testing
func newTestRepoService(mock *mockRepoService) *github.RepositoriesServiceImpl {
resolver := github.NewClientResolver(mock, nil, nil, nil, false)
impl := &github.RepositoriesServiceImpl{
Tags: map[string]*github.ListTagsResult{},
Releases: map[string]*github.ListReleasesResult{},
Commits: map[string]*github.GetCommitSHA1Result{},
}
impl.SetResolver(resolver)
return impl
}
func TestController_getLatestVersionFromReleases(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
releases []*github.RepositoryRelease
listErr error
isStable bool
wantVersion string
wantErr bool
}{
{
name: "single semver release",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("v1.0.0")},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "multiple semver releases - returns highest",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("v1.0.0")},
{TagName: github.Ptr("v2.0.0")},
{TagName: github.Ptr("v1.5.0")},
},
wantVersion: "v2.0.0",
wantErr: false,
},
{
name: "mix of valid and invalid semver",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("v1.0.0")},
{TagName: github.Ptr("not-a-version")},
{TagName: github.Ptr("v2.0.0")},
},
wantVersion: "v2.0.0",
wantErr: false,
},
{
name: "only invalid versions - returns latest by string comparison",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("main")},
{TagName: github.Ptr("release")},
{TagName: github.Ptr("develop")},
},
wantVersion: "release",
wantErr: false,
},
{
name: "no releases",
releases: []*github.RepositoryRelease{},
wantVersion: "",
wantErr: false,
},
{
name: "nil releases",
releases: nil,
wantVersion: "",
wantErr: false,
},
{
name: "prerelease versions",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("v1.0.0-alpha")},
{TagName: github.Ptr("v1.0.0-beta")},
{TagName: github.Ptr("v1.0.0")},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "build metadata versions",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("v1.0.0+build.1")},
{TagName: github.Ptr("v1.0.0+build.2")},
{TagName: github.Ptr("v1.0.1")},
},
wantVersion: "v1.0.1",
wantErr: false,
},
{
name: "releases with nil tag names",
releases: []*github.RepositoryRelease{
{TagName: nil},
{TagName: github.Ptr("v1.0.0")},
{TagName: nil},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "API error",
releases: nil,
listErr: errors.New("API error"),
wantVersion: "",
wantErr: true,
},
{
name: "empty tag name",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("")},
{TagName: github.Ptr("v1.0.0")},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "stable version ignores prerelease when current is stable (issue #1095)",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("v6-beta"), Prerelease: github.Ptr(true)},
{TagName: github.Ptr("v5.0.0"), Prerelease: github.Ptr(false)},
{TagName: github.Ptr("v4.3.0"), Prerelease: github.Ptr(false)},
},
isStable: true,
wantVersion: "v5.0.0",
wantErr: false,
},
{
name: "prerelease version can update to newer prerelease (issue #1095)",
releases: []*github.RepositoryRelease{
{TagName: github.Ptr("v6-beta"), Prerelease: github.Ptr(true)},
{TagName: github.Ptr("v5.0.0"), Prerelease: github.Ptr(false)},
},
isStable: false,
wantVersion: "v6-beta",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mockRepo := &mockRepoService{
listReleasesFunc: func(_ context.Context, _, _ string, _ *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error) {
return tt.releases, nil, tt.listErr
},
}
c := &Controller{
repositoriesService: newTestRepoService(mockRepo),
}
ctx := t.Context()
logger := slog.New(slog.DiscardHandler)
gotVersion, err := c.getLatestVersionFromReleases(ctx, logger, "owner", "repo", tt.isStable, time.Time{})
if (err != nil) != tt.wantErr {
t.Errorf("getLatestVersionFromReleases() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotVersion != tt.wantVersion {
t.Errorf("getLatestVersionFromReleases() = %v, want %v", gotVersion, tt.wantVersion)
}
})
}
}
func Test_isStableVersion(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
version string
want bool
}{
{
name: "empty version",
version: "",
want: false,
},
{
name: "stable semver with v prefix",
version: "v1.2.3",
want: true,
},
{
name: "stable semver without v prefix",
version: "1.2.3",
want: true,
},
{
name: "prerelease version alpha",
version: "v1.2.3-alpha",
want: false,
},
{
name: "prerelease version beta",
version: "v1.2.3-beta.1",
want: false,
},
{
name: "prerelease version rc",
version: "v1.2.3-rc.1",
want: false,
},
{
name: "invalid version string",
version: "not-a-version",
want: false,
},
{
name: "branch name",
version: "main",
want: false,
},
{
name: "short version v3",
version: "v3",
want: true,
},
{
name: "short version with prerelease",
version: "v3-beta",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := isStableVersion(tt.version); got != tt.want {
t.Errorf("isStableVersion(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
func Test_checkTagCooldown(t *testing.T) { //nolint:funlen
t.Parallel()
now := time.Now()
cutoff := now.AddDate(0, 0, -7) // 7 days ago
tests := []struct {
name string
gitService GitService
sha string
cutoff time.Time
commitTime time.Time
want bool
}{
{
name: "zero cutoff - no cooldown check",
gitService: nil,
sha: "abc123",
cutoff: time.Time{},
want: false,
},
{
name: "nil git service - no cooldown check",
gitService: nil,
sha: "abc123",
cutoff: cutoff,
want: false,
},
{
name: "empty SHA - no cooldown check",
gitService: &mockGitService{},
sha: "",
cutoff: cutoff,
want: false,
},
{
name: "commit before cutoff - not skipped",
gitService: &mockGitService{
getCommitFunc: func(_ context.Context, _, _, _ string) (*github.Commit, *github.Response, error) {
beforeCutoff := github.Timestamp{Time: cutoff.AddDate(0, 0, -1)}
return &github.Commit{
Committer: &github.CommitAuthor{
Date: &beforeCutoff,
},
}, nil, nil
},
},
sha: "abc123",
cutoff: cutoff,
want: false,
},
{
name: "commit after cutoff - skipped",
gitService: &mockGitService{
getCommitFunc: func(_ context.Context, _, _, _ string) (*github.Commit, *github.Response, error) {
afterCutoff := github.Timestamp{Time: cutoff.AddDate(0, 0, 1)}
return &github.Commit{
Committer: &github.CommitAuthor{
Date: &afterCutoff,
},
}, nil, nil
},
},
sha: "abc123",
cutoff: cutoff,
want: true,
},
{
name: "API error - skipped",
gitService: &mockGitService{
getCommitFunc: func(_ context.Context, _, _, _ string) (*github.Commit, *github.Response, error) {
return nil, nil, errors.New("API error")
},
},
sha: "abc123",
cutoff: cutoff,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.New(slog.DiscardHandler)
got := checkTagCooldown(ctx, logger, tt.gitService, "owner", "repo", "v1.0.0", tt.sha, tt.cutoff)
if got != tt.want {
t.Errorf("checkTagCooldown() = %v, want %v", got, tt.want)
}
})
}
}
type mockGitService struct {
getCommitFunc func(ctx context.Context, owner, repo, sha string) (*github.Commit, *github.Response, error)
}
func (m *mockGitService) GetCommit(ctx context.Context, _ *slog.Logger, owner, repo, sha string) (*github.Commit, *github.Response, error) {
if m.getCommitFunc != nil {
return m.getCommitFunc(ctx, owner, repo, sha)
}
return nil, nil, errors.New("not implemented")
}
func TestController_getLatestVersionFromTags(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
tags []*github.RepositoryTag
listErr error
wantVersion string
wantErr bool
}{
{
name: "single semver tag",
tags: []*github.RepositoryTag{
{Name: github.Ptr("v1.0.0")},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "multiple semver tags - returns highest",
tags: []*github.RepositoryTag{
{Name: github.Ptr("v1.0.0")},
{Name: github.Ptr("v2.0.0")},
{Name: github.Ptr("v1.5.0")},
},
wantVersion: "v2.0.0",
wantErr: false,
},
{
name: "mix of valid and invalid semver",
tags: []*github.RepositoryTag{
{Name: github.Ptr("v1.0.0")},
{Name: github.Ptr("not-a-version")},
{Name: github.Ptr("v2.0.0")},
},
wantVersion: "v2.0.0",
wantErr: false,
},
{
name: "only invalid versions - returns latest by string comparison",
tags: []*github.RepositoryTag{
{Name: github.Ptr("main")},
{Name: github.Ptr("release")},
{Name: github.Ptr("develop")},
},
wantVersion: "release",
wantErr: false,
},
{
name: "no tags",
tags: []*github.RepositoryTag{},
wantVersion: "",
wantErr: false,
},
{
name: "nil tags",
tags: nil,
wantVersion: "",
wantErr: false,
},
{
name: "prerelease versions",
tags: []*github.RepositoryTag{
{Name: github.Ptr("v1.0.0-alpha")},
{Name: github.Ptr("v1.0.0-beta")},
{Name: github.Ptr("v1.0.0")},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "build metadata versions",
tags: []*github.RepositoryTag{
{Name: github.Ptr("v1.0.0+build.1")},
{Name: github.Ptr("v1.0.0+build.2")},
{Name: github.Ptr("v1.0.1")},
},
wantVersion: "v1.0.1",
wantErr: false,
},
{
name: "tags with nil names",
tags: []*github.RepositoryTag{
{Name: nil},
{Name: github.Ptr("v1.0.0")},
{Name: nil},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "API error",
tags: nil,
listErr: errors.New("API error"),
wantVersion: "",
wantErr: true,
},
{
name: "empty tag name",
tags: []*github.RepositoryTag{
{Name: github.Ptr("")},
{Name: github.Ptr("v1.0.0")},
},
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "tags without v prefix",
tags: []*github.RepositoryTag{
{Name: github.Ptr("1.0.0")},
{Name: github.Ptr("2.0.0")},
{Name: github.Ptr("1.5.0")},
},
wantVersion: "2.0.0",
wantErr: false,
},
{
name: "mixed v prefix and no prefix",
tags: []*github.RepositoryTag{
{Name: github.Ptr("v1.0.0")},
{Name: github.Ptr("2.0.0")},
{Name: github.Ptr("v1.5.0")},
},
wantVersion: "2.0.0",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mockRepo := &mockRepoService{
listTagsFunc: func(_ context.Context, _, _ string, _ *github.ListOptions) ([]*github.RepositoryTag, *github.Response, error) {
return tt.tags, nil, tt.listErr
},
}
c := &Controller{
repositoriesService: newTestRepoService(mockRepo),
}
ctx := t.Context()
logger := slog.New(slog.DiscardHandler)
gotVersion, err := c.getLatestVersionFromTags(ctx, logger, "owner", "repo", false, time.Time{})
if (err != nil) != tt.wantErr {
t.Errorf("getLatestVersionFromTags() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotVersion != tt.wantVersion {
t.Errorf("getLatestVersionFromTags() = %v, want %v", gotVersion, tt.wantVersion)
}
})
}
}
07070100000050000081A4000000000000000000000001693FE2CD000003D7000000000000000000000000000000000000003200000000pinact-3.6.0/pkg/controller/run/list_workflows.gopackage run
import (
"fmt"
"path/filepath"
"github.com/suzuki-shunsuke/slog-error/slogerr"
)
// listWorkflows discovers GitHub Actions workflow and composite action files.
// It searches for YAML files in standard locations including .github/workflows
// and action.yaml files in various directory structures.
//
// Returns a slice of discovered file paths or an error if globbing fails.
func listWorkflows() ([]string, error) {
patterns := []string{
".github/workflows/*.yml",
".github/workflows/*.yaml",
"action.yml",
"action.yaml",
"*/action.yml",
"*/action.yaml",
"*/*/action.yml",
"*/*/action.yaml",
"*/*/*/action.yml",
"*/*/*/action.yaml",
}
files := []string{}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("look for workflow or composite action files using glob: %w", slogerr.With(err, "pattern", pattern))
}
files = append(files, matches...)
}
return files, nil
}
07070100000051000081A4000000000000000000000001693FE2CD000006F1000000000000000000000000000000000000002700000000pinact-3.6.0/pkg/controller/run/log.gopackage run
import (
"fmt"
"io"
"github.com/fatih/color"
)
type colorFunc func(a ...any) string
type Logger struct {
stderr io.Writer
red colorFunc
green colorFunc
}
// NewLogger creates a new Logger with colored output.
// It initializes color functions for red and green text output
// and configures the stderr writer for log output.
//
// Parameters:
// - stderr: writer for error output
//
// Returns a pointer to the configured Logger.
func NewLogger(stderr io.Writer) *Logger {
return &Logger{
red: color.New(color.FgRed).SprintFunc(),
green: color.New(color.FgGreen).SprintFunc(),
stderr: stderr,
}
}
const levelError = "error"
// Output writes formatted log messages with color coding and line information.
// It displays the log level, message, file location, and optionally shows
// before/after line changes with color-coded diff format.
//
// Parameters:
// - level: log level ("error" for red, others for default)
// - message: log message to display
// - line: line information including file path and line number
// - newLine: new line content for diff display (empty for no diff)
func (l *Logger) Output(level, message string, line *Line, newLine string) {
s := "INFO"
if level == levelError {
s = l.red("ERROR")
}
if newLine == "" {
if message == "" {
fmt.Fprintf(l.stderr, `%s:%d
%s
`, line.File, line.Number, line.Line)
return
}
fmt.Fprintf(l.stderr, `%s %s
%s:%d
%s
`, s, message, line.File, line.Number, line.Line)
return
}
if message == "" {
fmt.Fprintf(l.stderr, `%s:%d
%s
%s
`, line.File, line.Number, l.red("- "+line.Line), l.green("+ "+newLine))
return
}
fmt.Fprintf(l.stderr, `%s %s
%s:%d
%s
%s
`, s, message, line.File, line.Number, l.red("- "+line.Line), l.green("+ "+newLine))
}
07070100000052000081A4000000000000000000000001693FE2CD00000F1E000000000000000000000000000000000000003500000000pinact-3.6.0/pkg/controller/run/log_internal_test.gopackage run
import (
"bytes"
"strings"
"testing"
)
func TestNewLogger(t *testing.T) {
t.Parallel()
buf := &bytes.Buffer{}
logger := NewLogger(buf)
if logger == nil {
t.Fatal("NewLogger() returned nil")
}
if logger.stderr != buf {
t.Error("NewLogger() stderr not set correctly")
}
if logger.red == nil {
t.Error("NewLogger() red function is nil")
}
if logger.green == nil {
t.Error("NewLogger() green function is nil")
}
}
func TestLogger_Output(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
level string
message string
line *Line
newLine string
wantContains []string
wantNotContain []string
}{
{
name: "info level without new line",
level: "info",
message: "action isn't pinned",
line: &Line{
File: "test.yml",
Number: 10,
Line: " uses: actions/checkout@v3",
},
newLine: "",
wantContains: []string{
"INFO",
"action isn't pinned",
"test.yml:10",
" uses: actions/checkout@v3",
},
wantNotContain: []string{
"ERROR",
"+ ",
},
},
{
name: "error level without new line",
level: "error",
message: "failed to handle a line",
line: &Line{
File: "workflow.yml",
Number: 25,
Line: " - uses: custom/action@main",
},
newLine: "",
wantContains: []string{
"ERROR",
"failed to handle a line",
"workflow.yml:25",
},
},
{
name: "info level with diff",
level: "info",
message: "action isn't pinned",
line: &Line{
File: "test.yml",
Number: 10,
Line: " - uses: actions/checkout@v3",
},
newLine: " - uses: actions/checkout@abc123 # v3",
wantContains: []string{
"INFO",
"action isn't pinned",
"test.yml:10",
"- ", // old line prefix
"+ ", // new line prefix
"actions/checkout@v3", // old version
"actions/checkout@abc123 # v3", // new version
},
},
{
name: "error level with diff",
level: "error",
message: "action isn't pinned",
line: &Line{
File: "ci.yml",
Number: 5,
Line: " uses: owner/repo@v1",
},
newLine: " uses: owner/repo@def456 # v1.0.0",
wantContains: []string{
"ERROR",
"action isn't pinned",
"ci.yml:5",
"- ",
"+ ",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
buf := &bytes.Buffer{}
logger := NewLogger(buf)
logger.Output(tt.level, tt.message, tt.line, tt.newLine)
output := buf.String()
for _, want := range tt.wantContains {
if !strings.Contains(output, want) {
t.Errorf("Output() missing expected content %q in:\n%s", want, output)
}
}
for _, notWant := range tt.wantNotContain {
if strings.Contains(output, notWant) {
t.Errorf("Output() contains unexpected content %q in:\n%s", notWant, output)
}
}
})
}
}
func TestLogger_Output_format(t *testing.T) {
t.Parallel()
buf := &bytes.Buffer{}
logger := NewLogger(buf)
line := &Line{
File: "test.yml",
Number: 42,
Line: "original line",
}
// Test without new line
logger.Output("info", "test message", line, "")
output := buf.String()
// Verify format: "LEVEL message\nfile:line\noriginal\n"
lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
if len(lines) != 3 {
t.Errorf("Expected 3 lines in output without newLine, got %d: %v", len(lines), lines)
}
// Test with new line
buf.Reset()
logger.Output("info", "test message", line, "new line")
output = buf.String()
// Verify format: "LEVEL message\nfile:line\n- original\n+ new\n"
lines = strings.Split(strings.TrimRight(output, "\n"), "\n")
if len(lines) != 4 {
t.Errorf("Expected 4 lines in output with newLine, got %d: %v", len(lines), lines)
}
}
07070100000053000081A4000000000000000000000001693FE2CD00003602000000000000000000000000000000000000002E00000000pinact-3.6.0/pkg/controller/run/parse_line.gopackage run
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"github.com/hashicorp/go-version"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
"github.com/suzuki-shunsuke/slog-error/slogerr"
)
var (
usesPattern = regexp.MustCompile(`^( *(?:- )?['"]?uses['"]? *: +)(['"]?)(.*?)@([^ '"]+)['"]?(?:( +# +(?:tag=)?)(v?\d+[^ ]*)(.*))?`)
fullCommitSHAPattern = regexp.MustCompile(`\b[0-9a-f]{40}\b`)
semverPattern = regexp.MustCompile(`^v?\d+\.\d+\.\d+[^ ]*$`)
shortTagPattern = regexp.MustCompile(`^v?\d+(\.\d+)?$`)
)
type Action struct {
Uses string
Name string
Version string
VersionComment string
VersionCommentSeparator string
RepoOwner string
RepoName string
Quote string
Suffix string
}
type VersionType int
const (
Semver VersionType = iota
Shortsemver
FullCommitSHA
Empty
Other
)
// getVersionType determines the type of version string.
// It analyzes the version format to classify it as semantic version,
// short semantic version, full commit SHA, empty, or other.
func getVersionType(v string) VersionType {
if v == "" {
return Empty
}
if fullCommitSHAPattern.MatchString(v) {
return FullCommitSHA
}
if semverPattern.MatchString(v) {
return Semver
}
if shortTagPattern.MatchString(v) {
return Shortsemver
}
return Other
}
// parseAction extracts action information from a YAML line.
// It uses regular expressions to parse 'uses' statements and extract
// action name, version, comments, and formatting details.
func parseAction(line string) *Action {
matches := usesPattern.FindStringSubmatch(line)
if matches == nil {
return nil
}
return &Action{
Uses: matches[1], // " - uses: "
Quote: matches[2], // empty, ', "
Name: matches[3], // local action is excluded by the regular expression because local action doesn't have version @
Version: matches[4], // full commit hash, main, v3, v3.0.0
VersionCommentSeparator: matches[5], // empty, " # ", " # tag="
VersionComment: matches[6], // empty, v1, v3.0.0
Suffix: matches[7],
}
}
var ErrCantPinned = errors.New("action can't be pinned")
// ignoreAction checks if an action should be ignored based on configuration.
// It evaluates the action against all ignore rules in the configuration.
func (c *Controller) ignoreAction(logger *slog.Logger, action *Action) bool {
for _, ignoreAction := range c.cfg.IgnoreActions {
f, err := ignoreAction.Match(action.Name, action.Version, c.cfg.Version)
if err != nil {
slogerr.WithError(logger, err).Warn("match the action")
continue
}
if f {
return true
}
}
return false
}
// excludeAction checks if an action should be excluded based on exclude patterns.
// It tests the action name against all configured exclude regular expressions.
func (c *Controller) excludeAction(actionName string) bool {
for _, exclude := range c.param.Excludes {
if exclude.MatchString(actionName) {
return true
}
}
return false
}
// excludeByIncludes checks if an action should be excluded due to include patterns.
// When include patterns are specified, only actions matching include patterns
// are processed, so this returns true if the action doesn't match any include pattern.
func (c *Controller) excludeByIncludes(actionName string) bool {
if len(c.param.Includes) == 0 {
return false
}
for _, include := range c.param.Includes {
if include.MatchString(actionName) {
return false
}
}
return true
}
// parseLine processes a single line from a workflow file.
// It parses the line for action usage, applies filtering rules, and determines
// what modifications (if any) should be made based on the operation mode.
func (c *Controller) parseLine(ctx context.Context, logger *slog.Logger, line string) (s string, e error) {
attrs := slogerr.NewAttrs(2) //nolint:mnd
defer func() {
e = attrs.With(e)
}()
action := parseAction(line)
if action == nil {
logger.Debug("unmatch")
return "", nil
}
logger = attrs.Add(logger, "action", action.Name+"@"+action.Version)
if c.shouldSkipAction(logger, action) {
return "", nil
}
if !c.parseActionName(action) {
logger.Debug("ignore line")
return "", nil
}
return c.processVersionComment(ctx, logger, action, attrs)
}
// shouldSkipAction checks if an action should be skipped based on filtering rules.
func (c *Controller) shouldSkipAction(logger *slog.Logger, action *Action) bool {
if c.ignoreAction(logger, action) {
logger.Debug("ignore the action")
return true
}
if c.excludeAction(action.Name) {
logger.Debug("exclude the action")
return true
}
if c.excludeByIncludes(action.Name) {
logger.Debug("exclude the action")
return true
}
return false
}
// processVersionComment processes the action based on its version comment type.
func (c *Controller) processVersionComment(ctx context.Context, logger *slog.Logger, action *Action, attrs *slogerr.Attrs) (string, error) {
switch getVersionType(action.VersionComment) {
case Empty:
// @xxx
// Note that comments like "hoge" are treated as Empty
return c.parseNoTagLine(ctx, logger, action)
case Semver:
// @xxx # v1.0.0
return c.parseSemverTagLine(ctx, logger, action)
case Shortsemver:
// @xxx # v1
logger = attrs.Add(logger, "version_annotation", action.VersionComment)
return c.parseShortSemverTagLine(ctx, logger, action)
default:
// @xxx # hoge
if getVersionType(action.Version) == FullCommitSHA {
// @<full commit sha> # hoge
return "", nil
}
// @<not full commit sha> # hoge
return "", ErrCantPinned
}
}
// parseNoTagLine processes actions without version comments.
// It handles pinning actions that don't have version annotations,
// either by updating to latest version or converting tags to commit SHAs.
func (c *Controller) parseNoTagLine(ctx context.Context, logger *slog.Logger, action *Action) (string, error) {
typ := getVersionType(action.Version)
switch typ {
case Shortsemver, Semver:
case FullCommitSHA:
return "", nil
default:
return "", ErrCantPinned
}
// @v1, @v1.0.0
if c.param.Update {
return c.updateToLatestVersion(ctx, logger, action)
}
return c.pinCurrentVersion(ctx, logger, action, typ)
}
// updateToLatestVersion updates an action to its latest version.
func (c *Controller) updateToLatestVersion(ctx context.Context, logger *slog.Logger, action *Action) (string, error) {
lv, err := c.getLatestVersion(ctx, logger, action.RepoOwner, action.RepoName, action.Version)
if err != nil {
return "", fmt.Errorf("get the latest version: %w", err)
}
sha, _, err := c.repositoriesService.GetCommitSHA1(ctx, logger, action.RepoOwner, action.RepoName, lv, "")
if err != nil {
return "", fmt.Errorf("get a reference: %w", err)
}
return patchLine(action, sha, lv), nil
}
// pinCurrentVersion pins the current version to a commit SHA.
func (c *Controller) pinCurrentVersion(ctx context.Context, logger *slog.Logger, action *Action, typ VersionType) (string, error) {
// Get commit hash from tag
// https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#get-a-reference
sha, _, err := c.repositoriesService.GetCommitSHA1(ctx, logger, action.RepoOwner, action.RepoName, action.Version, "")
if err != nil {
return "", fmt.Errorf("get a reference: %w", err)
}
longVersion := action.Version
if typ == Shortsemver {
v, err := c.getLongVersionFromSHA(ctx, logger, action, sha)
if err != nil {
return "", err
}
if v != "" {
longVersion = v
}
}
return patchLine(action, sha, longVersion), nil
}
// compareVersion compares two version strings.
// It attempts semantic version comparison first, falling back to
// string comparison if semantic parsing fails.
func compareVersion(currentVersion, newVersion string) bool {
cv, err := version.NewVersion(currentVersion)
if err != nil {
return newVersion > currentVersion
}
nv, err := version.NewVersion(newVersion)
if err != nil {
return newVersion > currentVersion
}
return nv.GreaterThan(cv)
}
// parseSemverTagLine processes actions with semantic version comments.
// It handles updating semantic versions to latest and verifying that
// commit SHAs match their corresponding version tags.
func (c *Controller) parseSemverTagLine(ctx context.Context, logger *slog.Logger, action *Action) (string, error) {
// @xxx # v3.0.0
if c.param.Update { //nolint:nestif
// get the latest version
lv, err := c.getLatestVersion(ctx, logger, action.RepoOwner, action.RepoName, action.VersionComment)
if err != nil {
return "", fmt.Errorf("get the latest version: %w", err)
}
if action.VersionComment == lv {
return "", nil
}
if !compareVersion(action.VersionComment, lv) {
logger.Warn("skip updating because the current version is newer than the new version",
"current_version", action.VersionComment,
"new_version", lv,
)
return "", nil
}
if action.VersionComment != lv {
sha, _, err := c.repositoriesService.GetCommitSHA1(ctx, logger, action.RepoOwner, action.RepoName, lv, "")
if err != nil {
return "", fmt.Errorf("get the latest version: %w", err)
}
return patchLine(action, sha, lv), nil
}
}
// verify commit hash
if !c.param.IsVerify {
return "", nil
}
// @xxx # v3.0.0
// @<full commit hash> # v3.0.0
if FullCommitSHA != getVersionType(action.Version) {
return "", nil
}
if err := c.verify(ctx, logger, action); err != nil {
return "", fmt.Errorf("verify the version annotation: %w", err)
}
return "", nil
}
// parseShortSemverTagLine processes actions with short semantic version comments.
// It handles expanding short versions (like v3) to full versions (like v3.1.0)
// and updating to latest versions when requested.
func (c *Controller) parseShortSemverTagLine(ctx context.Context, logger *slog.Logger, action *Action) (string, error) {
// @xxx # v3
// @<full commit hash> # v3
if FullCommitSHA != getVersionType(action.Version) {
return "", ErrCantPinned
}
if c.param.Update {
lv, err := c.getLatestVersion(ctx, logger, action.RepoOwner, action.RepoName, action.VersionComment)
if err != nil {
return "", fmt.Errorf("get the latest version: %w", err)
}
sha, _, err := c.repositoriesService.GetCommitSHA1(ctx, logger, action.RepoOwner, action.RepoName, lv, "")
if err != nil {
return "", fmt.Errorf("get the latest version: %w", err)
}
return patchLine(action, sha, lv), nil
}
// replace Shortsemer to Semver
longVersion, err := c.getLongVersionFromSHA(ctx, logger, action, action.Version)
if err != nil {
return "", err
}
if longVersion == "" {
logger.Debug("a long tag whose SHA is same as SHA of the version annotation isn't found")
return "", nil
}
return patchLine(action, action.Version, longVersion), nil
}
// patchLine reconstructs a workflow line with updated version and tag.
// It combines the action information with new version and tag to create
// the updated line with proper formatting and comments.
func patchLine(action *Action, version, tag string) string {
sep := action.VersionCommentSeparator
if sep == "" {
sep = " # "
}
return action.Uses + action.Quote + action.Name + "@" + version + action.Quote + sep + tag + action.Suffix
}
// getLongVersionFromSHA finds the full semantic version tag for a commit SHA.
// It searches through repository tags to find a tag that points to the given
// commit and matches the version comment prefix.
func (c *Controller) getLongVersionFromSHA(ctx context.Context, logger *slog.Logger, action *Action, sha string) (string, error) {
opts := &github.ListOptions{
PerPage: 100, //nolint:mnd
}
// Get long tag from commit hash
for range 10 {
tags, resp, err := c.repositoriesService.ListTags(ctx, logger, action.RepoOwner, action.RepoName, opts)
if err != nil {
return "", fmt.Errorf("list tags: %w", err)
}
for _, tag := range tags {
if sha != tag.GetCommit().GetSHA() {
continue
}
tagName := tag.GetName()
if action.VersionComment == "" {
if action.Version == tagName {
continue
}
} else {
if action.VersionComment == tagName {
continue
}
}
if strings.HasPrefix(tagName, action.VersionComment) {
return tagName, nil
}
}
if resp.NextPage == 0 {
return "", nil
}
opts.Page = resp.NextPage
}
return "", nil
}
// parseActionName extracts repository owner and name from action name.
// It parses the action name to extract the repository owner and name
// components, which are needed for GitHub API calls.
func (c *Controller) parseActionName(action *Action) bool {
a := strings.Split(action.Name, "/")
if len(a) == 1 {
// If it fails to extract the repository owner and name, ignore the action.
return false
}
action.RepoOwner = a[0]
action.RepoName = a[1]
return true
}
// verify checks that an action's version SHA matches its version comment.
// It validates that the commit SHA in the action version matches the
// commit SHA of the version specified in the comment.
func (c *Controller) verify(ctx context.Context, logger *slog.Logger, action *Action) error {
sha, _, err := c.repositoriesService.GetCommitSHA1(ctx, logger, action.RepoOwner, action.RepoName, action.VersionComment, "")
if err != nil {
return fmt.Errorf("get a commit hash: %w", err)
}
if action.Version == sha {
return nil
}
return slogerr.With(errors.New("action_version must be equal to commit_hash_of_version_annotation"), //nolint:wrapcheck
"action", action.Name,
"action_version", action.Version,
"version_annotation", action.VersionComment,
"commit_hash_of_version_annotation", sha,
"help_docs", "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/001.md",
)
}
07070100000054000081A4000000000000000000000001693FE2CD00004092000000000000000000000000000000000000003C00000000pinact-3.6.0/pkg/controller/run/parse_line_internal_test.gopackage run
import (
"log/slog"
"regexp"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
)
func strP(s string) *string {
return &s
}
func Test_parseAction(t *testing.T) { //nolint:funlen
t.Parallel()
data := []struct {
name string
line string
exp *Action
}{
{
name: "unrelated",
line: "unrelated",
},
{
name: "checkout v3",
line: " - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3",
exp: &Action{
Uses: " - uses: ",
Name: "actions/checkout",
Version: "8e5e7e5ab8b370d6c329ec480221332ada57f0ab",
VersionCommentSeparator: " # ",
VersionComment: "v3",
},
},
{
name: "checkout v2",
line: " uses: actions/checkout@v2",
exp: &Action{
Uses: " uses: ",
Name: "actions/checkout",
Version: "v2",
},
},
{
name: "checkout v3 (single quote)",
line: ` - "uses": 'actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab' # v3`,
exp: &Action{
Uses: ` - "uses": `,
Name: "actions/checkout",
Version: "8e5e7e5ab8b370d6c329ec480221332ada57f0ab",
VersionCommentSeparator: " # ",
VersionComment: "v3",
Quote: "'",
},
},
{
name: "checkout v3 (double quote)",
line: ` - 'uses': "actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab" # v3`,
exp: &Action{
Uses: ` - 'uses': `,
Name: "actions/checkout",
Version: "8e5e7e5ab8b370d6c329ec480221332ada57f0ab",
VersionCommentSeparator: " # ",
VersionComment: "v3",
Quote: `"`,
},
},
{
name: "checkout v2 (single quote)",
line: ` "uses": 'actions/checkout@v2'`,
exp: &Action{
Uses: ` "uses": `,
Name: "actions/checkout",
Version: "v2",
VersionComment: "",
Quote: `'`,
},
},
{
name: "checkout v2 (double quote)",
line: ` 'uses': "actions/checkout@v2"`,
exp: &Action{
Uses: ` 'uses': `,
Name: "actions/checkout",
Version: "v2",
VersionComment: "",
Quote: `"`,
},
},
{
name: "tag=",
line: ` - uses: actions/checkout@83b7061638ee4956cf7545a6f7efe594e5ad0247 # tag=v3`,
exp: &Action{
Uses: ` - uses: `,
Name: "actions/checkout",
Version: "83b7061638ee4956cf7545a6f7efe594e5ad0247",
VersionCommentSeparator: " # tag=",
VersionComment: "v3",
Quote: "",
},
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
act := parseAction(d.line)
if diff := cmp.Diff(d.exp, act); diff != "" {
t.Fatal(diff)
}
})
}
}
func TestController_parseLine(t *testing.T) { //nolint:funlen
t.Parallel()
data := []struct {
name string
line string
exp string
isErr bool
}{
{
name: "unrelated",
line: "unrelated",
},
{
name: "checkout v3",
line: " - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3",
exp: " - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2",
},
{
name: "checkout v2",
line: " uses: actions/checkout@v2",
exp: " uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0",
},
{
name: "single quote",
line: ` - "uses": 'actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab' # v3`,
exp: ` - "uses": 'actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab' # v3.5.2`,
},
{
name: "double quote",
line: ` - 'uses': "actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab" # v3`,
exp: ` - 'uses': "actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab" # v3.5.2`,
},
{
name: "checkout v2 (single quote)",
line: ` "uses": 'actions/checkout@v2'`,
exp: ` "uses": 'actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5' # v2.7.0`,
},
}
logger := slog.New(slog.DiscardHandler)
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := New(&github.RepositoriesServiceImpl{
Tags: map[string]*github.ListTagsResult{
"actions/checkout/0": {
Tags: []*github.RepositoryTag{
{
Name: strP("v3"),
Commit: &github.Commit{
SHA: strP("8e5e7e5ab8b370d6c329ec480221332ada57f0ab"),
},
},
{
Name: strP("v3.5.2"),
Commit: &github.Commit{
SHA: strP("8e5e7e5ab8b370d6c329ec480221332ada57f0ab"),
},
},
{
Name: strP("v2"),
Commit: &github.Commit{
SHA: strP("ee0669bd1cc54295c223e0bb666b733df41de1c5"),
},
},
{
Name: strP("v2.7.0"),
Commit: &github.Commit{
SHA: strP("ee0669bd1cc54295c223e0bb666b733df41de1c5"),
},
},
},
Response: &github.Response{},
},
},
Releases: map[string]*github.ListReleasesResult{
"actions/checkout/0": {
Releases: []*github.RepositoryRelease{}, // Empty releases forces fallback to tags
Response: &github.Response{},
},
},
Commits: map[string]*github.GetCommitSHA1Result{
"actions/checkout/v3": {
SHA: "8e5e7e5ab8b370d6c329ec480221332ada57f0ab",
},
"actions/checkout/v2": {
SHA: "ee0669bd1cc54295c223e0bb666b733df41de1c5",
},
},
}, nil, nil, fs, &config.Config{}, &ParamRun{})
line, err := ctrl.parseLine(t.Context(), logger, d.line)
if err != nil {
if d.isErr {
return
}
t.Fatal(err)
}
if line != d.exp {
t.Fatalf(`wanted %s, got %s`, d.exp, line)
}
})
}
}
func Test_patchLine(t *testing.T) {
t.Parallel()
data := []struct {
name string
tag string
version string
action *Action
exp string
}{
{
name: "checkout v3",
exp: " - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v3.5.2",
action: &Action{
Uses: " - uses: ",
Name: "actions/checkout",
Version: "8e5e7e5ab8b370d6c329ec480221332ada57f0ab",
VersionCommentSeparator: " # ",
VersionComment: "v3",
},
version: "ee0669bd1cc54295c223e0bb666b733df41de1c5",
tag: "v3.5.2",
},
{
name: "checkout v2",
exp: " uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.17.0",
action: &Action{
Uses: " uses: ",
Name: "actions/checkout",
Version: "v2",
},
version: "ee0669bd1cc54295c223e0bb666b733df41de1c5",
tag: "v2.17.0",
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
line := patchLine(d.action, d.version, d.tag)
if line != d.exp {
t.Fatalf(`wanted %s, got %s`, d.exp, line)
}
})
}
}
func Test_getVersionType(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
version string
want VersionType
}{
{
name: "empty string",
version: "",
want: Empty,
},
{
name: "full commit SHA",
version: "8e5e7e5ab8b370d6c329ec480221332ada57f0ab",
want: FullCommitSHA,
},
{
name: "semver with v prefix",
version: "v1.2.3",
want: Semver,
},
{
name: "semver without v prefix",
version: "1.2.3",
want: Semver,
},
{
name: "semver with prerelease",
version: "v1.2.3-alpha",
want: Semver,
},
{
name: "semver with build metadata",
version: "v1.2.3+build.1",
want: Semver,
},
{
name: "short semver v3",
version: "v3",
want: Shortsemver,
},
{
name: "short semver v3.1",
version: "v3.1",
want: Shortsemver,
},
{
name: "short semver without v prefix",
version: "3",
want: Shortsemver,
},
{
name: "short semver minor without v",
version: "3.1",
want: Shortsemver,
},
{
name: "branch name main",
version: "main",
want: Other,
},
{
name: "branch name master",
version: "master",
want: Other,
},
{
name: "short SHA",
version: "abc1234",
want: Other,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := getVersionType(tt.version); got != tt.want {
t.Errorf("getVersionType(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
func Test_compareVersion(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
currentVersion string
newVersion string
want bool
}{
{
name: "new version is greater",
currentVersion: "v1.0.0",
newVersion: "v2.0.0",
want: true,
},
{
name: "new version is less",
currentVersion: "v2.0.0",
newVersion: "v1.0.0",
want: false,
},
{
name: "versions are equal",
currentVersion: "v1.0.0",
newVersion: "v1.0.0",
want: false,
},
{
name: "minor version is greater",
currentVersion: "v1.0.0",
newVersion: "v1.1.0",
want: true,
},
{
name: "patch version is greater",
currentVersion: "v1.0.0",
newVersion: "v1.0.1",
want: true,
},
{
name: "invalid current version - string comparison",
currentVersion: "main",
newVersion: "release",
want: true,
},
{
name: "invalid new version - string comparison",
currentVersion: "v1.0.0",
newVersion: "invalid",
want: false,
},
{
name: "both invalid - string comparison greater",
currentVersion: "alpha",
newVersion: "beta",
want: true,
},
{
name: "both invalid - string comparison less",
currentVersion: "beta",
newVersion: "alpha",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := compareVersion(tt.currentVersion, tt.newVersion); got != tt.want {
t.Errorf("compareVersion(%q, %q) = %v, want %v", tt.currentVersion, tt.newVersion, got, tt.want)
}
})
}
}
func TestController_shouldSkipAction(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
cfg *config.Config
param *ParamRun
action *Action
want bool
}{
{
name: "no filters - should not skip",
cfg: &config.Config{},
param: &ParamRun{},
action: &Action{
Name: "actions/checkout",
Version: "v3",
},
want: false,
},
{
name: "excluded by exclude pattern",
cfg: &config.Config{},
param: &ParamRun{
Excludes: []*regexp.Regexp{regexp.MustCompile(`^actions/.*`)},
},
action: &Action{
Name: "actions/checkout",
Version: "v3",
},
want: true,
},
{
name: "not excluded by non-matching exclude pattern",
cfg: &config.Config{},
param: &ParamRun{
Excludes: []*regexp.Regexp{regexp.MustCompile(`^other/.*`)},
},
action: &Action{
Name: "actions/checkout",
Version: "v3",
},
want: false,
},
{
name: "included by include pattern",
cfg: &config.Config{},
param: &ParamRun{
Includes: []*regexp.Regexp{regexp.MustCompile(`^actions/.*`)},
},
action: &Action{
Name: "actions/checkout",
Version: "v3",
},
want: false,
},
{
name: "excluded by non-matching include pattern",
cfg: &config.Config{},
param: &ParamRun{
Includes: []*regexp.Regexp{regexp.MustCompile(`^other/.*`)},
},
action: &Action{
Name: "actions/checkout",
Version: "v3",
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := New(nil, nil, nil, fs, tt.cfg, tt.param)
logger := slog.New(slog.DiscardHandler)
if got := ctrl.shouldSkipAction(logger, tt.action); got != tt.want {
t.Errorf("shouldSkipAction() = %v, want %v", got, tt.want)
}
})
}
}
func TestController_parseActionName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
action *Action
want bool
wantRepoOwner string
wantRepoName string
}{
{
name: "valid action name",
action: &Action{
Name: "actions/checkout",
},
want: true,
wantRepoOwner: "actions",
wantRepoName: "checkout",
},
{
name: "action with path",
action: &Action{
Name: "owner/repo/path/to/action",
},
want: true,
wantRepoOwner: "owner",
wantRepoName: "repo",
},
{
name: "single component name",
action: &Action{
Name: "localaction",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := New(nil, nil, nil, fs, &config.Config{}, &ParamRun{})
got := ctrl.parseActionName(tt.action)
if got != tt.want {
t.Errorf("parseActionName() = %v, want %v", got, tt.want)
}
if tt.want {
if tt.action.RepoOwner != tt.wantRepoOwner {
t.Errorf("RepoOwner = %v, want %v", tt.action.RepoOwner, tt.wantRepoOwner)
}
if tt.action.RepoName != tt.wantRepoName {
t.Errorf("RepoName = %v, want %v", tt.action.RepoName, tt.wantRepoName)
}
}
})
}
}
func TestController_excludeAction(t *testing.T) {
t.Parallel()
tests := []struct {
name string
excludes []*regexp.Regexp
actionName string
want bool
}{
{
name: "no excludes",
excludes: nil,
actionName: "actions/checkout",
want: false,
},
{
name: "empty excludes",
excludes: []*regexp.Regexp{},
actionName: "actions/checkout",
want: false,
},
{
name: "matching exclude pattern",
excludes: []*regexp.Regexp{regexp.MustCompile(`actions/checkout`)},
actionName: "actions/checkout",
want: true,
},
{
name: "non-matching exclude pattern",
excludes: []*regexp.Regexp{regexp.MustCompile(`other/action`)},
actionName: "actions/checkout",
want: false,
},
{
name: "multiple excludes - one matches",
excludes: []*regexp.Regexp{
regexp.MustCompile(`other/action`),
regexp.MustCompile(`actions/.*`),
},
actionName: "actions/checkout",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := New(nil, nil, nil, fs, &config.Config{}, &ParamRun{Excludes: tt.excludes})
if got := ctrl.excludeAction(tt.actionName); got != tt.want {
t.Errorf("excludeAction() = %v, want %v", got, tt.want)
}
})
}
}
func TestController_excludeByIncludes(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
includes []*regexp.Regexp
actionName string
want bool
}{
{
name: "no includes - not excluded",
includes: nil,
actionName: "actions/checkout",
want: false,
},
{
name: "empty includes - not excluded",
includes: []*regexp.Regexp{},
actionName: "actions/checkout",
want: false,
},
{
name: "matching include - not excluded",
includes: []*regexp.Regexp{regexp.MustCompile(`actions/.*`)},
actionName: "actions/checkout",
want: false,
},
{
name: "non-matching include - excluded",
includes: []*regexp.Regexp{regexp.MustCompile(`other/.*`)},
actionName: "actions/checkout",
want: true,
},
{
name: "multiple includes - one matches - not excluded",
includes: []*regexp.Regexp{
regexp.MustCompile(`other/.*`),
regexp.MustCompile(`actions/.*`),
},
actionName: "actions/checkout",
want: false,
},
{
name: "multiple includes - none match - excluded",
includes: []*regexp.Regexp{
regexp.MustCompile(`other/.*`),
regexp.MustCompile(`another/.*`),
},
actionName: "actions/checkout",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := New(nil, nil, nil, fs, &config.Config{}, &ParamRun{Includes: tt.includes})
if got := ctrl.excludeByIncludes(tt.actionName); got != tt.want {
t.Errorf("excludeByIncludes() = %v, want %v", got, tt.want)
}
})
}
}
07070100000055000081A4000000000000000000000001693FE2CD00001F07000000000000000000000000000000000000002700000000pinact-3.6.0/pkg/controller/run/run.gopackage run
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/suzuki-shunsuke/slog-error/slogerr"
"github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave"
)
type ParamRun struct {
WorkflowFilePaths []string
ConfigFilePath string
PWD string
IsVerify bool
Update bool
Check bool
IsGitHubActions bool
Fix bool
Diff bool
Stderr io.Writer
Review *Review
Includes []*regexp.Regexp
Excludes []*regexp.Regexp
MinAge int
Now time.Time
}
type Review struct {
RepoOwner string
RepoName string
PullRequest int
SHA string
}
// Valid checks if the review configuration has all required fields.
// It validates that repo owner, repo name, and pull request number are set,
// returning true if the review configuration is valid for creating reviews.
func (r *Review) Valid() bool {
return r != nil && r.RepoOwner != "" && r.RepoName != "" && r.PullRequest > 0
}
// Run executes the main pinact operation.
// It searches for workflow files and processes each file
// to pin GitHub Actions versions according to the specified parameters.
func (c *Controller) Run(ctx context.Context, logger *slog.Logger) error {
workflowFilePaths, err := c.searchFiles()
if err != nil {
return fmt.Errorf("search target files: %w", err)
}
failed := false
for _, workflowFilePath := range workflowFilePaths {
logger := logger.With("workflow_file", workflowFilePath)
if err := c.runWorkflow(ctx, logger, workflowFilePath); err != nil {
failed = true
if errors.Is(err, ErrActionsNotPinned) {
continue
}
if c.param.Check {
slogerr.WithError(logger, err).Error("check a workflow")
continue
}
slogerr.WithError(logger, err).Error("update a workflow")
}
}
if failed {
return urfave.ErrSilent
}
return nil
}
var ErrActionsNotPinned = errors.New("action aren't pinned")
type Line struct {
File string
Number int
Line string
}
// runWorkflow processes a single workflow file.
// It reads the file line by line, parses each line for actions,
// applies transformations, and optionally writes changes back to the file.
func (c *Controller) runWorkflow(ctx context.Context, logger *slog.Logger, workflowFilePath string) error {
lines, err := c.readWorkflow(workflowFilePath)
if err != nil {
return err
}
changed, failed := c.processLines(ctx, logger, workflowFilePath, lines)
if changed && c.param.Fix {
if err := c.writeWorkflow(workflowFilePath, lines); err != nil {
return err
}
}
if failed {
return ErrActionsNotPinned
}
return nil
}
// processLines processes each line in the workflow file.
// It returns whether any lines were changed and whether any errors occurred.
func (c *Controller) processLines(ctx context.Context, logger *slog.Logger, workflowFilePath string, lines []string) (changed, failed bool) {
for i, lineS := range lines {
line := &Line{
File: workflowFilePath,
Number: i + 1,
Line: lineS,
}
lineLogger := logger.With(
"line_number", i+1,
"line", lineS,
)
l, err := c.parseLine(ctx, lineLogger, lineS)
if err != nil {
failed = true
c.handleParseLineError(ctx, lineLogger, line, err)
continue
}
if l == "" || lineS == l {
continue
}
lineLogger = lineLogger.With("new_line", l)
changed = true
if c.param.Check {
failed = true
}
lines[i] = l
c.handleChangedLine(ctx, lineLogger, line, l)
}
return changed, failed
}
// writeWorkflow writes the modified lines back to the workflow file.
func (c *Controller) writeWorkflow(workflowFilePath string, lines []string) error {
f, err := c.fs.Create(workflowFilePath)
if err != nil {
return fmt.Errorf("create a workflow file: %w", err)
}
defer f.Close()
if _, err := f.WriteString(strings.Join(lines, "\n") + "\n"); err != nil {
return fmt.Errorf("write a workflow file: %w", err)
}
return nil
}
// handleParseLineError handles errors that occur during line parsing.
// It outputs error messages, creates GitHub Actions annotations, and
// optionally creates pull request review comments.
func (c *Controller) handleParseLineError(ctx context.Context, logger *slog.Logger, line *Line, gErr error) {
// Output error
c.logger.Output(levelError, "failed to handle a line: "+gErr.Error(), line, "")
if c.param.Review == nil {
// Output GitHub Actions error
if c.param.IsGitHubActions {
fmt.Fprintf(c.param.Stderr, "::error file=%s,line=%d,title=pinact error::%s\n", line.File, line.Number, gErr)
}
return
}
// Create review
if code, err := c.review(ctx, line.File, c.param.Review.SHA, line.Number, "", gErr); err != nil {
level := slog.LevelError
if code == http.StatusUnprocessableEntity {
level = slog.LevelWarn
}
slogerr.WithError(logger, err).Log(ctx, level, "create a review comment",
"review_repo_owner", c.param.Review.RepoOwner,
"review_repo_name", c.param.Review.RepoName,
"review_pr_number", c.param.Review.PullRequest,
"review_sha", c.param.Review.SHA,
)
// Output GitHub Actions error
if c.param.IsGitHubActions {
fmt.Fprintf(c.param.Stderr, "::error file=%s,line=%d,title=pinact error::%s\n", line.File, line.Number, gErr)
}
}
}
// handleChangedLine handles lines that have been modified.
// It creates review comments, GitHub Actions annotations, and outputs
// diff information depending on the operation mode.
func (c *Controller) handleChangedLine(ctx context.Context, logger *slog.Logger, line *Line, newLine string) {
reviewed := c.tryCreateReview(ctx, logger, line, newLine)
c.outputGitHubActionsAnnotation(line, newLine, reviewed)
c.outputDiff(line, newLine)
}
// tryCreateReview attempts to create a PR review comment for the changed line.
// Returns true if the review was created successfully.
func (c *Controller) tryCreateReview(ctx context.Context, logger *slog.Logger, line *Line, newLine string) bool {
if c.param.Review == nil {
return false
}
code, err := c.review(ctx, line.File, c.param.Review.SHA, line.Number, newLine, nil)
if err != nil {
level := slog.LevelError
if code == http.StatusUnprocessableEntity {
level = slog.LevelWarn
}
slogerr.WithError(logger, err).Log(ctx, level, "create a review comment",
"review_repo_owner", c.param.Review.RepoOwner,
"review_repo_name", c.param.Review.RepoName,
"review_pr_number", c.param.Review.PullRequest,
"review_sha", c.param.Review.SHA,
)
return false
}
return true
}
// outputGitHubActionsAnnotation outputs a GitHub Actions annotation for the changed line.
func (c *Controller) outputGitHubActionsAnnotation(line *Line, newLine string, reviewed bool) {
if !c.param.IsGitHubActions || reviewed {
return
}
level := "notice"
if c.param.Check {
level = levelError
}
fmt.Fprintf(c.param.Stderr, "::%s file=%s,line=%d,title=pinact error::%s\n", level, line.File, line.Number, newLine)
}
// outputDiff outputs the diff information for the changed line.
func (c *Controller) outputDiff(line *Line, newLine string) {
if !c.param.Check && c.param.Fix && !c.param.Diff {
return
}
level := "info"
if c.param.Check {
level = levelError
}
c.logger.Output(level, "", line, newLine)
}
// readWorkflow reads a workflow file and returns its lines.
// It opens the file and scans it line by line, returning all lines
// as a slice of strings.
func (c *Controller) readWorkflow(workflowFilePath string) ([]string, error) {
workflowReadFile, err := os.Open(workflowFilePath)
if err != nil {
return nil, fmt.Errorf("open a workflow file: %w", err)
}
defer workflowReadFile.Close()
scanner := bufio.NewScanner(workflowReadFile)
lines := []string{}
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan a workflow file: %w", err)
}
return lines, nil
}
07070100000056000081A4000000000000000000000001693FE2CD00001A82000000000000000000000000000000000000003500000000pinact-3.6.0/pkg/controller/run/run_internal_test.gopackage run
import (
"bytes"
"context"
"log/slog"
"os"
"testing"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
)
func TestReview_Valid(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
review *Review
want bool
}{
{
name: "nil review",
review: nil,
want: false,
},
{
name: "empty review",
review: &Review{},
want: false,
},
{
name: "missing repo owner",
review: &Review{
RepoName: "repo",
PullRequest: 1,
},
want: false,
},
{
name: "missing repo name",
review: &Review{
RepoOwner: "owner",
PullRequest: 1,
},
want: false,
},
{
name: "missing pull request",
review: &Review{
RepoOwner: "owner",
RepoName: "repo",
},
want: false,
},
{
name: "zero pull request",
review: &Review{
RepoOwner: "owner",
RepoName: "repo",
PullRequest: 0,
},
want: false,
},
{
name: "valid review",
review: &Review{
RepoOwner: "owner",
RepoName: "repo",
PullRequest: 123,
},
want: true,
},
{
name: "valid review with SHA",
review: &Review{
RepoOwner: "owner",
RepoName: "repo",
PullRequest: 456,
SHA: "abc123",
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.review.Valid(); got != tt.want {
t.Errorf("Review.Valid() = %v, want %v", got, tt.want)
}
})
}
}
func TestController_processLines(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
lines []string
param *ParamRun
wantChanged bool
wantFailed bool
}{
{
name: "empty lines",
lines: []string{},
param: &ParamRun{Stderr: &bytes.Buffer{}},
wantChanged: false,
wantFailed: false,
},
{
name: "no action lines",
lines: []string{
"name: Test Workflow",
"on: push",
"jobs:",
" test:",
" runs-on: ubuntu-latest",
},
param: &ParamRun{Stderr: &bytes.Buffer{}},
wantChanged: false,
wantFailed: false,
},
{
name: "already pinned action with semver comment",
lines: []string{
" - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2",
},
param: &ParamRun{Stderr: &bytes.Buffer{}},
wantChanged: false,
wantFailed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := New(&github.RepositoriesServiceImpl{
Tags: map[string]*github.ListTagsResult{
"actions/checkout/0": {
Tags: []*github.RepositoryTag{
{
Name: github.Ptr("v3.5.2"),
Commit: &github.Commit{
SHA: github.Ptr("8e5e7e5ab8b370d6c329ec480221332ada57f0ab"),
},
},
},
Response: &github.Response{},
},
},
Releases: map[string]*github.ListReleasesResult{
"actions/checkout/0": {
Releases: []*github.RepositoryRelease{},
Response: &github.Response{},
},
},
Commits: map[string]*github.GetCommitSHA1Result{},
}, nil, nil, fs, &config.Config{}, tt.param)
logger := slog.New(slog.DiscardHandler)
linesCopy := make([]string, len(tt.lines))
copy(linesCopy, tt.lines)
changed, failed := ctrl.processLines(context.Background(), logger, "test.yml", linesCopy)
if changed != tt.wantChanged {
t.Errorf("processLines() changed = %v, want %v", changed, tt.wantChanged)
}
if failed != tt.wantFailed {
t.Errorf("processLines() failed = %v, want %v", failed, tt.wantFailed)
}
})
}
}
func TestController_readWorkflow(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
content string
wantLines []string
wantErr bool
}{
{
name: "empty file",
content: "",
wantLines: []string{},
wantErr: false,
},
{
name: "single line",
content: "name: Test",
wantLines: []string{"name: Test"},
wantErr: false,
},
{
name: "multiple lines",
content: `name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest`,
wantLines: []string{
"name: Test",
"on: push",
"jobs:",
" test:",
" runs-on: ubuntu-latest",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Create a temporary file for testing (readWorkflow uses os.Open, not afero)
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.yml")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(tt.content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
fs := afero.NewMemMapFs()
ctrl := &Controller{fs: fs}
lines, err := ctrl.readWorkflow(tmpFile.Name())
if (err != nil) != tt.wantErr {
t.Errorf("readWorkflow() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(lines) != len(tt.wantLines) {
t.Errorf("readWorkflow() got %d lines, want %d", len(lines), len(tt.wantLines))
return
}
for i, line := range lines {
if line != tt.wantLines[i] {
t.Errorf("readWorkflow() line[%d] = %q, want %q", i, line, tt.wantLines[i])
}
}
})
}
}
func TestController_readWorkflow_fileNotFound(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := &Controller{fs: fs}
_, err := ctrl.readWorkflow("nonexistent-file-that-does-not-exist.yml")
if err == nil {
t.Error("readWorkflow() expected error for non-existent file, got nil")
}
}
func TestController_writeWorkflow(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lines []string
wantContent string
}{
{
name: "empty lines",
lines: []string{},
wantContent: "\n",
},
{
name: "single line",
lines: []string{"name: Test"},
wantContent: "name: Test\n",
},
{
name: "multiple lines",
lines: []string{
"name: Test",
"on: push",
"jobs:",
},
wantContent: "name: Test\non: push\njobs:\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := &Controller{fs: fs}
err := ctrl.writeWorkflow("test.yml", tt.lines)
if err != nil {
t.Errorf("writeWorkflow() error = %v", err)
return
}
content, err := afero.ReadFile(fs, "test.yml")
if err != nil {
t.Errorf("failed to read written file: %v", err)
return
}
if string(content) != tt.wantContent {
t.Errorf("writeWorkflow() wrote %q, want %q", string(content), tt.wantContent)
}
})
}
}
07070100000057000081A4000000000000000000000001693FE2CD000004EF000000000000000000000000000000000000002F00000000pinact-3.6.0/pkg/controller/run/search_file.gopackage run
import (
"fmt"
"path/filepath"
)
// searchFiles determines which files to process based on configuration.
// It returns workflow file paths from command line arguments if provided,
// otherwise uses configured file patterns, or falls back to default discovery.
//
// Returns a slice of file paths to process and any error encountered.
func (c *Controller) searchFiles() ([]string, error) {
if len(c.param.WorkflowFilePaths) != 0 {
return c.param.WorkflowFilePaths, nil
}
if c.cfg != nil && len(c.cfg.Files) > 0 {
return c.searchFilesByGlob()
}
return listWorkflows()
}
// searchFilesByGlob finds files using glob patterns from configuration.
// It applies each configured file pattern as a glob relative to the
// configuration file directory and collects all matching files.
//
// Returns a slice of matching file paths and any error encountered.
func (c *Controller) searchFilesByGlob() ([]string, error) {
files := []string{}
configFileDir := filepath.Dir(c.param.ConfigFilePath)
for _, file := range c.cfg.Files {
matches, err := filepath.Glob(filepath.Join(configFileDir, file.Pattern))
if err != nil {
return nil, fmt.Errorf("search target files: %w", err)
}
files = append(files, matches...)
}
return files, nil
}
07070100000058000081A4000000000000000000000001693FE2CD00000B9E000000000000000000000000000000000000003D00000000pinact-3.6.0/pkg/controller/run/search_file_internal_test.gopackage run
import (
"testing"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
)
func TestController_searchFiles(t *testing.T) { //nolint:funlen
t.Parallel()
tests := []struct {
name string
param *ParamRun
cfg *config.Config
want []string
wantErr bool
}{
{
name: "use workflow file paths from param",
param: &ParamRun{
WorkflowFilePaths: []string{"workflow1.yml", "workflow2.yml"},
},
cfg: &config.Config{},
want: []string{"workflow1.yml", "workflow2.yml"},
wantErr: false,
},
{
name: "empty workflow file paths with config files - uses glob",
param: &ParamRun{
ConfigFilePath: ".pinact.yaml",
},
cfg: &config.Config{
Files: []*config.File{
{Pattern: "*.yml"},
},
},
// Note: This will return empty because filepath.Glob uses real filesystem
want: nil,
wantErr: false,
},
{
name: "nil config - fallback to listWorkflows",
param: &ParamRun{},
cfg: nil,
// Note: listWorkflows uses real filesystem, will return empty in test
want: nil,
wantErr: false,
},
{
name: "empty config files - fallback to listWorkflows",
param: &ParamRun{},
cfg: &config.Config{},
// Note: listWorkflows uses real filesystem, will return empty in test
want: nil,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := New(nil, nil, nil, fs, tt.cfg, tt.param)
got, err := ctrl.searchFiles()
if (err != nil) != tt.wantErr {
t.Errorf("searchFiles() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Only check length for workflow file paths from param case
if tt.param.WorkflowFilePaths != nil {
if len(got) != len(tt.want) {
t.Errorf("searchFiles() got %d files, want %d", len(got), len(tt.want))
return
}
for i, path := range got {
if path != tt.want[i] {
t.Errorf("searchFiles()[%d] = %v, want %v", i, path, tt.want[i])
}
}
}
})
}
}
func TestController_searchFiles_withWorkflowPaths(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
param := &ParamRun{
WorkflowFilePaths: []string{"a.yml", "b.yml", "c.yml"},
}
ctrl := New(nil, nil, nil, fs, &config.Config{}, param)
got, err := ctrl.searchFiles()
if err != nil {
t.Errorf("searchFiles() error = %v", err)
return
}
if len(got) != 3 {
t.Errorf("searchFiles() got %d files, want 3", len(got))
}
}
func TestController_searchFilesByGlob_emptyFiles(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
ctrl := &Controller{
fs: fs,
cfg: &config.Config{
Files: []*config.File{},
},
param: &ParamRun{
ConfigFilePath: ".pinact.yaml",
},
}
got, err := ctrl.searchFilesByGlob()
if err != nil {
t.Errorf("searchFilesByGlob() error = %v", err)
return
}
if len(got) != 0 {
t.Errorf("searchFilesByGlob() got %d files, want 0", len(got))
}
}
07070100000059000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001400000000pinact-3.6.0/pkg/di0707010000005A000081A4000000000000000000000001693FE2CD000003E2000000000000000000000000000000000000001B00000000pinact-3.6.0/pkg/di/env.gopackage di
// Secrets holds sensitive tokens for GitHub API authentication.
type Secrets struct {
GitHubToken string
GHESToken string
}
// SetFromEnv sets secrets from environment variables.
func (s *Secrets) SetFromEnv(getEnv func(string) string) {
s.GitHubToken = getEnv("GITHUB_TOKEN")
for _, envName := range []string{"GHES_TOKEN", "GITHUB_TOKEN_ENTERPRISE", "GITHUB_ENTERPRISE_TOKEN"} {
if token := getEnv(envName); token != "" {
s.GHESToken = token
}
}
}
// SetEnv populates flags from environment variables.
func SetEnv(flags *Flags, getEnv func(string) string) {
flags.GitHubRepository = getEnv("GITHUB_REPOSITORY")
flags.GitHubAPIURL = getEnv("GITHUB_API_URL")
flags.GitHubEventPath = getEnv("GITHUB_EVENT_PATH")
flags.GHESAPIURL = getEnv("GHES_API_URL")
trueS := "true"
flags.IsGitHubActions = getEnv("GITHUB_ACTIONS") == trueS
flags.FallbackEnabled = getEnv("PINACT_GHES_FALLBACK") == trueS
flags.KeyringEnabled = getEnv("PINACT_KEYRING_ENABLED") == trueS
}
0707010000005B000081A4000000000000000000000001693FE2CD00000A95000000000000000000000000000000000000002000000000pinact-3.6.0/pkg/di/env_test.gopackage di_test
import (
"testing"
"github.com/suzuki-shunsuke/pinact/v3/pkg/di"
)
func TestSecrets_SetFromEnv(t *testing.T) {
t.Parallel()
data := []struct {
name string
env map[string]string
expGitHubToken string
expGHESToken string
}{
{
name: "empty",
env: map[string]string{},
expGitHubToken: "",
expGHESToken: "",
},
{
name: "github token only",
env: map[string]string{"GITHUB_TOKEN": "gh_token"},
expGitHubToken: "gh_token",
expGHESToken: "",
},
{
name: "ghes token",
env: map[string]string{"GITHUB_TOKEN": "gh_token", "GHES_TOKEN": "ghes_token"},
expGitHubToken: "gh_token",
expGHESToken: "ghes_token",
},
{
name: "github token enterprise",
env: map[string]string{"GITHUB_TOKEN": "gh_token", "GITHUB_TOKEN_ENTERPRISE": "enterprise_token"},
expGitHubToken: "gh_token",
expGHESToken: "enterprise_token",
},
{
name: "github enterprise token",
env: map[string]string{"GITHUB_TOKEN": "gh_token", "GITHUB_ENTERPRISE_TOKEN": "enterprise_token2"},
expGitHubToken: "gh_token",
expGHESToken: "enterprise_token2",
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
s := &di.Secrets{}
s.SetFromEnv(func(key string) string {
return d.env[key]
})
if s.GitHubToken != d.expGitHubToken {
t.Errorf("GitHubToken: wanted %q, got %q", d.expGitHubToken, s.GitHubToken)
}
if s.GHESToken != d.expGHESToken {
t.Errorf("GHESToken: wanted %q, got %q", d.expGHESToken, s.GHESToken)
}
})
}
}
func TestSetEnv(t *testing.T) {
t.Parallel()
data := []struct {
name string
env map[string]string
expGitHubRepo string
expIsGitHubActions bool
}{
{
name: "empty",
env: map[string]string{},
expGitHubRepo: "",
expIsGitHubActions: false,
},
{
name: "all values set",
env: map[string]string{
"GITHUB_REPOSITORY": "owner/repo",
"GITHUB_ACTIONS": "true",
},
expGitHubRepo: "owner/repo",
expIsGitHubActions: true,
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
flags := &di.Flags{}
di.SetEnv(flags, func(key string) string {
return d.env[key]
})
if flags.GitHubRepository != d.expGitHubRepo {
t.Errorf("GitHubRepository: wanted %q, got %q", d.expGitHubRepo, flags.GitHubRepository)
}
if flags.IsGitHubActions != d.expIsGitHubActions {
t.Errorf("IsGitHubActions: wanted %v, got %v", d.expIsGitHubActions, flags.IsGitHubActions)
}
})
}
}
0707010000005C000081A4000000000000000000000001693FE2CD00000768000000000000000000000000000000000000001D00000000pinact-3.6.0/pkg/di/event.gopackage di
import (
"encoding/json"
"fmt"
"github.com/spf13/afero"
)
// Event represents a GitHub Actions event payload.
type Event struct {
PullRequest *PullRequest `json:"pull_request"`
Issue *Issue `json:"issue"`
Repository *Repository `json:"repository"`
}
// RepoName extracts the repository name from the GitHub event.
func (e *Event) RepoName() string {
if e != nil && e.Repository != nil {
return e.Repository.Name
}
return ""
}
// PRNumber extracts the pull request or issue number from the GitHub event.
func (e *Event) PRNumber() int {
if e == nil {
return 0
}
if e.PullRequest != nil {
return e.PullRequest.Number
}
if e.Issue != nil {
return e.Issue.Number
}
return 0
}
// SHA extracts the commit SHA from the GitHub event.
func (e *Event) SHA() string {
if e == nil {
return ""
}
if e.PullRequest != nil && e.PullRequest.Head != nil {
return e.PullRequest.Head.SHA
}
return ""
}
// Issue represents a GitHub issue in the event payload.
type Issue struct {
Number int `json:"number"`
}
// PullRequest represents a GitHub pull request in the event payload.
type PullRequest struct {
Number int `json:"number"`
Head *Head `json:"head"`
}
// Repository represents a GitHub repository in the event payload.
type Repository struct {
Owner *Owner `json:"owner"`
Name string `json:"name"`
}
// Owner represents a GitHub repository owner in the event payload.
type Owner struct {
Login string `json:"login"`
}
// Head represents the head branch of a pull request.
type Head struct {
SHA string `json:"sha"`
}
func readEvent(fs afero.Fs, ev *Event, eventPath string) error {
event, err := fs.Open(eventPath)
if err != nil {
return fmt.Errorf("read GITHUB_EVENT_PATH: %w", err)
}
if err := json.NewDecoder(event).Decode(&ev); err != nil {
return fmt.Errorf("unmarshal GITHUB_EVENT_PATH: %w", err)
}
return nil
}
0707010000005D000081A4000000000000000000000001693FE2CD000007FC000000000000000000000000000000000000002200000000pinact-3.6.0/pkg/di/event_test.gopackage di_test
import (
"testing"
"github.com/suzuki-shunsuke/pinact/v3/pkg/di"
)
func TestEvent_RepoName(t *testing.T) {
t.Parallel()
data := []struct {
name string
event *di.Event
exp string
}{
{name: "nil event", event: nil, exp: ""},
{name: "nil repository", event: &di.Event{}, exp: ""},
{name: "with repository", event: &di.Event{Repository: &di.Repository{Name: "my-repo"}}, exp: "my-repo"},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
if got := d.event.RepoName(); got != d.exp {
t.Errorf("wanted %q, got %q", d.exp, got)
}
})
}
}
func TestEvent_PRNumber(t *testing.T) {
t.Parallel()
data := []struct {
name string
event *di.Event
exp int
}{
{name: "nil event", event: nil, exp: 0},
{name: "empty event", event: &di.Event{}, exp: 0},
{name: "pull request", event: &di.Event{PullRequest: &di.PullRequest{Number: 123}}, exp: 123},
{name: "issue", event: &di.Event{Issue: &di.Issue{Number: 456}}, exp: 456},
{
name: "both - pull request takes precedence",
event: &di.Event{PullRequest: &di.PullRequest{Number: 123}, Issue: &di.Issue{Number: 456}},
exp: 123,
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
if got := d.event.PRNumber(); got != d.exp {
t.Errorf("wanted %d, got %d", d.exp, got)
}
})
}
}
func TestEvent_SHA(t *testing.T) {
t.Parallel()
data := []struct {
name string
event *di.Event
exp string
}{
{name: "nil event", event: nil, exp: ""},
{name: "empty event", event: &di.Event{}, exp: ""},
{name: "pull request without head", event: &di.Event{PullRequest: &di.PullRequest{Number: 123}}, exp: ""},
{
name: "pull request with head",
event: &di.Event{PullRequest: &di.PullRequest{Number: 123, Head: &di.Head{SHA: "abc123"}}},
exp: "abc123",
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
if got := d.event.SHA(); got != d.exp {
t.Errorf("wanted %q, got %q", d.exp, got)
}
})
}
}
0707010000005E000081A4000000000000000000000001693FE2CD00000624000000000000000000000000000000000000001C00000000pinact-3.6.0/pkg/di/flag.gopackage di
import (
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/flag"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
)
// Flags holds all command-line flags for the run command.
type Flags struct {
*flag.GlobalFlags
Verify bool
Check bool
Update bool
Review bool
Fix bool
Diff bool
IsGitHubActions bool
FallbackEnabled bool
KeyringEnabled bool
RepoOwner string
RepoName string
SHA string
GitHubRepository string
GitHubAPIURL string
GitHubEventPath string
GHESAPIURL string
PWD string
FixCount int
PR int
MinAge int
Include []string
Exclude []string
Args []string
}
const defaultGitHubAPIURL = "https://api.github.com"
// GetAPIURL returns the GHES API URL from environment variables.
func (f *Flags) GetAPIURL() string {
if f.GHESAPIURL != "" {
return f.GHESAPIURL
}
if f.GitHubAPIURL == "" || f.GitHubAPIURL == defaultGitHubAPIURL {
return ""
}
return f.GitHubAPIURL
}
// GHESFromEnv creates a GHES configuration from environment variables.
func (f *Flags) GHESFromEnv() *config.GHES {
apiURL := f.GetAPIURL()
if apiURL == "" {
return nil
}
return &config.GHES{
APIURL: apiURL,
Fallback: f.FallbackEnabled,
}
}
// MergeFromEnv merges environment variable values into GHES configuration.
func (f *Flags) MergeFromEnv(g *config.GHES) {
if g == nil {
return
}
if g.APIURL == "" {
g.APIURL = f.GetAPIURL()
}
// Environment variable can enable fallback (but not disable it if already set in config)
if !g.Fallback && f.FallbackEnabled {
g.Fallback = true
}
}
0707010000005F000081A4000000000000000000000001693FE2CD00000941000000000000000000000000000000000000002100000000pinact-3.6.0/pkg/di/flag_test.gopackage di_test
import (
"testing"
"github.com/suzuki-shunsuke/pinact/v3/pkg/di"
)
func TestFlags_GetAPIURL(t *testing.T) {
t.Parallel()
data := []struct {
name string
ghesAPIURL string
githubAPIURL string
exp string
}{
{name: "empty", ghesAPIURL: "", githubAPIURL: "", exp: ""},
{name: "ghes api url set", ghesAPIURL: "https://ghes.example.com/api/v3", githubAPIURL: "", exp: "https://ghes.example.com/api/v3"},
{name: "github api url is default", ghesAPIURL: "", githubAPIURL: "https://api.github.com", exp: ""},
{name: "github api url is custom", ghesAPIURL: "", githubAPIURL: "https://custom.github.com/api/v3", exp: "https://custom.github.com/api/v3"},
{name: "ghes api url takes precedence", ghesAPIURL: "https://ghes.example.com/api/v3", githubAPIURL: "https://custom.github.com/api/v3", exp: "https://ghes.example.com/api/v3"},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
flags := &di.Flags{GHESAPIURL: d.ghesAPIURL, GitHubAPIURL: d.githubAPIURL}
if got := flags.GetAPIURL(); got != d.exp {
t.Errorf("wanted %q, got %q", d.exp, got)
}
})
}
}
func TestFlags_GHESFromEnv(t *testing.T) {
t.Parallel()
data := []struct {
name string
ghesAPIURL string
fallbackEnabled bool
expNil bool
expAPIURL string
expFallback bool
}{
{name: "no api url", ghesAPIURL: "", expNil: true},
{name: "with ghes api url", ghesAPIURL: "https://ghes.example.com/api/v3", expNil: false, expAPIURL: "https://ghes.example.com/api/v3", expFallback: false},
{name: "with fallback enabled", ghesAPIURL: "https://ghes.example.com/api/v3", fallbackEnabled: true, expNil: false, expAPIURL: "https://ghes.example.com/api/v3", expFallback: true},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
t.Parallel()
flags := &di.Flags{GHESAPIURL: d.ghesAPIURL, FallbackEnabled: d.fallbackEnabled}
got := flags.GHESFromEnv()
if d.expNil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected non-nil, got nil")
}
if got.APIURL != d.expAPIURL {
t.Errorf("APIURL: wanted %q, got %q", d.expAPIURL, got.APIURL)
}
if got.Fallback != d.expFallback {
t.Errorf("Fallback: wanted %v, got %v", d.expFallback, got.Fallback)
}
})
}
}
07070100000060000081A4000000000000000000000001693FE2CD00000871000000000000000000000000000000000000001C00000000pinact-3.6.0/pkg/di/ghes.gopackage di
import (
"context"
"fmt"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
)
// setupGHESServices creates GitHub API services with GHES (GitHub Enterprise Server) support.
// It configures a ClientResolver that routes API requests to either GHES or github.com
// based on the configuration. When GHES is enabled with fallback, repositories are first
// checked on GHES and fall back to github.com if not found.
func setupGHESServices(ctx context.Context, gh *github.Client, cfg *config.Config, flags *Flags, token string) (*ghesServices, error) {
ghesConfig := cfg.GHES
if ghesConfig == nil {
ghesConfig = flags.GHESFromEnv()
} else {
// Merge environment variables into config file settings
flags.MergeFromEnv(ghesConfig)
}
if err := ghesConfig.Validate(); err != nil {
return nil, fmt.Errorf("validate GHES configuration: %w", err)
}
var ghesRepoService github.RepositoriesService
var ghesGitService github.GitService
var ghesPRService github.PullRequestsService
var ghesFallback bool
if ghesConfig.IsEnabled() {
registry, err := github.NewClientRegistry(ctx, gh, ghesConfig, token)
if err != nil {
return nil, fmt.Errorf("create GitHub client registry: %w", err)
}
client := registry.GetGHESClient()
ghesRepoService = client.Repositories
ghesGitService = client.Git
ghesPRService = client.PullRequests
ghesFallback = ghesConfig.Fallback
}
resolver := github.NewClientResolver(
gh.Repositories, gh.Git,
ghesRepoService, ghesGitService,
ghesFallback,
)
repoService := &github.RepositoriesServiceImpl{
Tags: map[string]*github.ListTagsResult{},
Releases: map[string]*github.ListReleasesResult{},
Commits: map[string]*github.GetCommitSHA1Result{},
}
repoService.SetResolver(resolver)
gitService := &github.GitServiceImpl{
Commits: map[string]*github.GetCommitResult{},
}
gitService.SetResolver(resolver)
prService := &github.PullRequestsServiceImpl{}
prService.SetServices(gh.PullRequests, ghesPRService)
return &ghesServices{
repoService: repoService,
gitService: gitService,
prService: prService,
}, nil
}
07070100000061000081A4000000000000000000000001693FE2CD000008C4000000000000000000000000000000000000001E00000000pinact-3.6.0/pkg/di/review.gopackage di
import (
"fmt"
"strings"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/controller/run"
"github.com/suzuki-shunsuke/slog-error/slogerr"
"github.com/suzuki-shunsuke/slog-util/slogutil"
)
// populateReviewFromGitHubActionsEnv fills missing review fields from GitHub Actions environment.
// It extracts repository name from GITHUB_REPOSITORY and pull request number/SHA from the event file.
// This function is only called when running in GitHub Actions environment.
func populateReviewFromGitHubActionsEnv(fs afero.Fs, review *run.Review, flags *Flags) error {
if review.RepoName == "" {
repo := flags.GitHubRepository
_, repoName, ok := strings.Cut(repo, "/")
if !ok {
return fmt.Errorf("GITHUB_REPOSITORY is not set or invalid: %s", repo)
}
if repoName == "" {
return fmt.Errorf("GITHUB_REPOSITORY is invalid: %s", repo)
}
review.RepoName = repoName
}
if flags.GitHubEventPath == "" {
return nil
}
var ev *Event
if review.PullRequest == 0 {
ev = &Event{}
if err := readEvent(fs, ev, flags.GitHubEventPath); err != nil {
return err
}
review.PullRequest = ev.PRNumber()
}
if review.SHA != "" {
return nil
}
if ev == nil {
ev = &Event{}
if err := readEvent(fs, ev, flags.GitHubEventPath); err != nil {
return err
}
}
review.SHA = ev.SHA()
return nil
}
// setupReview creates a Review configuration for the -review flag.
// It initializes the review with command-line flags, and when running in GitHub Actions,
// automatically populates missing fields from environment variables and event file.
// Returns nil if review is disabled or the configuration is invalid.
func setupReview(fs afero.Fs, logger *slogutil.Logger, flags *Flags) *run.Review {
if !flags.Review {
return nil
}
review := &run.Review{
RepoOwner: flags.RepoOwner,
RepoName: flags.RepoName,
PullRequest: flags.PR,
SHA: flags.SHA,
}
if flags.IsGitHubActions {
if err := populateReviewFromGitHubActionsEnv(fs, review, flags); err != nil {
slogerr.WithError(logger.Logger, err).Error("set review information")
}
}
if !review.Valid() {
logger.Warn("skip creating reviews because the review information is invalid")
return nil
}
return review
}
07070100000062000081A4000000000000000000000001693FE2CD00000EA1000000000000000000000000000000000000002C00000000pinact-3.6.0/pkg/di/review_internal_test.gopackage di
import (
"testing"
"github.com/lmittmann/tint"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/flag"
"github.com/suzuki-shunsuke/pinact/v3/pkg/controller/run"
"github.com/suzuki-shunsuke/slog-util/slogutil"
)
func newTestLogger() *slogutil.Logger {
return slogutil.New(&slogutil.InputNew{
Name: "test",
Version: "test",
TintOptions: &tint.Options{NoColor: true},
})
}
func Test_populateReviewFromGitHubActionsEnv(t *testing.T) {
t.Parallel()
t.Run("already has repo name", func(t *testing.T) {
t.Parallel()
review := &run.Review{RepoName: "existing-repo"}
flags := &Flags{GitHubRepository: "owner/other-repo"}
if err := populateReviewFromGitHubActionsEnv(afero.NewMemMapFs(), review, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.RepoName != "existing-repo" {
t.Errorf("RepoName: wanted %q, got %q", "existing-repo", review.RepoName)
}
})
t.Run("extract repo name from GITHUB_REPOSITORY", func(t *testing.T) {
t.Parallel()
review := &run.Review{}
flags := &Flags{GitHubRepository: "owner/my-repo"}
if err := populateReviewFromGitHubActionsEnv(afero.NewMemMapFs(), review, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.RepoName != "my-repo" {
t.Errorf("RepoName: wanted %q, got %q", "my-repo", review.RepoName)
}
})
t.Run("invalid GITHUB_REPOSITORY - no slash", func(t *testing.T) {
t.Parallel()
review := &run.Review{}
flags := &Flags{GitHubRepository: "noslash"}
if err := populateReviewFromGitHubActionsEnv(afero.NewMemMapFs(), review, flags); err == nil {
t.Error("expected error, got nil")
}
})
t.Run("extract PR number from event", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
eventPath := "/tmp/event.json"
eventContent := `{"pull_request": {"number": 42, "head": {"sha": "abc123"}}}`
if err := afero.WriteFile(fs, eventPath, []byte(eventContent), 0o644); err != nil {
t.Fatal(err)
}
review := &run.Review{RepoName: "my-repo"}
flags := &Flags{GitHubEventPath: eventPath}
if err := populateReviewFromGitHubActionsEnv(fs, review, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.PullRequest != 42 {
t.Errorf("PullRequest: wanted %d, got %d", 42, review.PullRequest)
}
if review.SHA != "abc123" {
t.Errorf("SHA: wanted %q, got %q", "abc123", review.SHA)
}
})
}
func Test_setupReview(t *testing.T) {
t.Parallel()
logger := newTestLogger()
t.Run("review disabled", func(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Review: false}
if got := setupReview(afero.NewMemMapFs(), logger, flags); got != nil {
t.Errorf("expected nil, got %+v", got)
}
})
t.Run("review enabled with all fields", func(t *testing.T) {
t.Parallel()
flags := &Flags{
GlobalFlags: &flag.GlobalFlags{},
Review: true,
RepoOwner: "owner",
RepoName: "repo",
PR: 42,
SHA: "abc123",
}
got := setupReview(afero.NewMemMapFs(), logger, flags)
if got == nil {
t.Fatal("expected non-nil, got nil")
}
if got.RepoOwner != "owner" {
t.Errorf("RepoOwner: wanted %q, got %q", "owner", got.RepoOwner)
}
if got.RepoName != "repo" {
t.Errorf("RepoName: wanted %q, got %q", "repo", got.RepoName)
}
if got.PullRequest != 42 {
t.Errorf("PullRequest: wanted %d, got %d", 42, got.PullRequest)
}
})
t.Run("review enabled but invalid - missing required fields", func(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Review: true}
if got := setupReview(afero.NewMemMapFs(), logger, flags); got != nil {
t.Errorf("expected nil for invalid review, got %+v", got)
}
})
}
07070100000063000081A4000000000000000000000001693FE2CD00000D79000000000000000000000000000000000000001B00000000pinact-3.6.0/pkg/di/run.go// Package di provides dependency injection for the pinact CLI.
// It creates and wires together all the dependencies needed to run the pinact commands.
package di
import (
"context"
"fmt"
"os"
"regexp"
"time"
"github.com/fatih/color"
"github.com/spf13/afero"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
"github.com/suzuki-shunsuke/pinact/v3/pkg/controller/run"
"github.com/suzuki-shunsuke/pinact/v3/pkg/github"
"github.com/suzuki-shunsuke/slog-error/slogerr"
"github.com/suzuki-shunsuke/slog-util/slogutil"
)
type ghesServices struct {
repoService *github.RepositoriesServiceImpl
gitService *github.GitServiceImpl
prService *github.PullRequestsServiceImpl
}
// Run executes the main run command logic.
// It configures logging, processes GitHub Actions context, parses includes/excludes,
// sets up the controller, and executes the pinning operation.
func Run(ctx context.Context, logger *slogutil.Logger, flags *Flags, secrets *Secrets) error {
if flags.IsGitHubActions {
color.NoColor = false
}
if err := logger.SetLevel(flags.LogLevel); err != nil {
return fmt.Errorf("set log level: %w", err)
}
gh := github.New(ctx, logger.Logger, secrets.GitHubToken, flags.KeyringEnabled)
fs := afero.NewOsFs()
cfg, err := readConfig(fs, flags.Config)
if err != nil {
return err
}
review := setupReview(fs, logger, flags)
param, err := buildParam(flags, review)
if err != nil {
return err
}
services, err := setupGHESServices(ctx, gh, cfg, flags, secrets.GHESToken)
if err != nil {
return err
}
ctrl := run.New(services.repoService, services.prService, services.gitService, fs, cfg, param)
return ctrl.Run(ctx, logger.Logger) //nolint:wrapcheck
}
func readConfig(fs afero.Fs, configFilePath string) (*config.Config, error) {
cfgFinder := config.NewFinder(fs)
cfgReader := config.NewReader(fs)
configPath, err := cfgFinder.Find(configFilePath)
if err != nil {
return nil, fmt.Errorf("find configuration file: %w", err)
}
cfg := &config.Config{}
if err := cfgReader.Read(cfg, configPath); err != nil {
return nil, fmt.Errorf("read configuration file: %w", err)
}
return cfg, nil
}
func compileRegexps(opts []string) ([]*regexp.Regexp, error) {
regexps := make([]*regexp.Regexp, len(opts))
for i, include := range opts {
r, err := regexp.Compile(include)
if err != nil {
return nil, fmt.Errorf("compile a regexp: %w", slogerr.With(err, "regexp", include))
}
regexps[i] = r
}
return regexps, nil
}
func buildParam(flags *Flags, review *run.Review) (*run.ParamRun, error) {
includes, err := compileRegexps(flags.Include)
if err != nil {
return nil, fmt.Errorf("parse include: %w", err)
}
excludes, err := compileRegexps(flags.Exclude)
if err != nil {
return nil, fmt.Errorf("parse exclude: %w", err)
}
param := &run.ParamRun{
WorkflowFilePaths: flags.Args,
ConfigFilePath: flags.Config,
PWD: flags.PWD,
IsVerify: flags.Verify,
Check: flags.Check,
Update: flags.Update,
Diff: flags.Diff,
Fix: true,
IsGitHubActions: flags.IsGitHubActions,
Stderr: os.Stderr,
Review: review,
Includes: includes,
Excludes: excludes,
MinAge: flags.MinAge,
Now: time.Now(),
}
if flags.FixCount > 0 {
param.Fix = flags.Fix
} else if param.Check || param.Diff {
param.Fix = false
}
return param, nil
}
07070100000064000081A4000000000000000000000001693FE2CD00000B46000000000000000000000000000000000000002900000000pinact-3.6.0/pkg/di/run_internal_test.gopackage di
import (
"testing"
"github.com/suzuki-shunsuke/pinact/v3/pkg/cli/flag"
)
func Test_compileRegexps(t *testing.T) {
t.Parallel()
t.Run("empty", func(t *testing.T) {
t.Parallel()
got, err := compileRegexps([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("wanted 0, got %d", len(got))
}
})
t.Run("valid regexes", func(t *testing.T) {
t.Parallel()
got, err := compileRegexps([]string{"^foo", "bar$"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Fatalf("wanted 2, got %d", len(got))
}
if !got[0].MatchString("foobar") {
t.Error("expected ^foo to match 'foobar'")
}
if !got[1].MatchString("foobar") {
t.Error("expected bar$ to match 'foobar'")
}
})
t.Run("invalid regex", func(t *testing.T) {
t.Parallel()
if _, err := compileRegexps([]string{"[invalid"}); err == nil {
t.Error("expected error, got nil")
}
})
}
func Test_buildParam_default(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Args: []string{"test.yaml"}, PWD: "/tmp"}
got, err := buildParam(flags, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got.Fix {
t.Error("Fix: wanted true, got false")
}
if len(got.WorkflowFilePaths) != 1 {
t.Errorf("WorkflowFilePaths: wanted 1, got %d", len(got.WorkflowFilePaths))
}
}
func Test_buildParam_checkMode(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Check: true}
got, err := buildParam(flags, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Fix {
t.Error("Fix: wanted false, got true")
}
if !got.Check {
t.Error("Check: wanted true, got false")
}
}
func Test_buildParam_diffMode(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Diff: true}
got, err := buildParam(flags, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Fix {
t.Error("Fix: wanted false, got true")
}
}
func Test_buildParam_explicitFix(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Check: true, Fix: true, FixCount: 1}
got, err := buildParam(flags, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got.Fix {
t.Error("Fix: wanted true, got false")
}
}
func Test_buildParam_invalidRegex(t *testing.T) {
t.Parallel()
t.Run("invalid include", func(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Include: []string{"[invalid"}}
if _, err := buildParam(flags, nil); err == nil {
t.Error("expected error, got nil")
}
})
t.Run("invalid exclude", func(t *testing.T) {
t.Parallel()
flags := &Flags{GlobalFlags: &flag.GlobalFlags{}, Exclude: []string{"[invalid"}}
if _, err := buildParam(flags, nil); err == nil {
t.Error("expected error, got nil")
}
})
}
07070100000065000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001800000000pinact-3.6.0/pkg/github07070100000066000081A4000000000000000000000001693FE2CD00000C9A000000000000000000000000000000000000002200000000pinact-3.6.0/pkg/github/github.go// Package github provides GitHub API client integration and authentication.
// This package abstracts GitHub API operations, handling client creation with
// proper authentication through environment variables or OS keyring storage.
// It manages OAuth2 token-based authentication, provides type aliases for
// commonly used GitHub API types, and configures HTTP clients for API calls.
// The package supports both authenticated and unauthenticated API access,
// with automatic fallback mechanisms for different authentication sources.
package github
import (
"context"
"log/slog"
"net/http"
"github.com/google/go-github/v80/github"
"github.com/suzuki-shunsuke/urfave-cli-v3-util/keyring/ghtoken"
"golang.org/x/oauth2"
)
type (
ListOptions = github.ListOptions
Reference = github.Reference
Response = github.Response
Repository = github.Repository
RepositoryTag = github.RepositoryTag
RepositoryRelease = github.RepositoryRelease
Client = github.Client
GitObject = github.GitObject
Commit = github.Commit
CommitAuthor = github.CommitAuthor
Timestamp = github.Timestamp
PullRequestComment = github.PullRequestComment
)
// New creates a new GitHub API client with authentication.
// It configures the client with appropriate HTTP client based on available
// authentication methods (environment token or keyring).
func New(ctx context.Context, logger *slog.Logger, token string, keyringEnabled bool) *Client {
return github.NewClient(getHTTPClientForGitHub(ctx, logger, token, keyringEnabled))
}
// Ptr returns a pointer to the provided value.
// This is a convenience function that delegates to github.Ptr for
// creating pointers to values, commonly needed for GitHub API structs.
func Ptr[T any](v T) *T {
return github.Ptr(v)
}
// getHTTPClientForGitHub creates an HTTP client configured for GitHub API access.
// It handles authentication using environment token, keyring, or falls back
// to unauthenticated access. The client is configured with OAuth2 for authenticated requests.
func getHTTPClientForGitHub(ctx context.Context, logger *slog.Logger, token string, keyringEnabled bool) *http.Client {
if token == "" {
if keyringEnabled {
return oauth2.NewClient(ctx, ghtoken.NewTokenSource(logger, KeyService))
}
return http.DefaultClient
}
return oauth2.NewClient(ctx, oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
))
}
// NewWithBaseURL creates a new GitHub API client with a custom base URL.
// This is used for GitHub Enterprise Server instances.
func NewWithBaseURL(ctx context.Context, baseURL, token string) (*Client, error) {
httpClient := getHTTPClientForGitHubWithToken(ctx, token)
return github.NewClient(httpClient).WithEnterpriseURLs(baseURL, baseURL) //nolint:wrapcheck
}
// getHTTPClientForGitHubWithToken creates an HTTP client with a specific token.
// Unlike getHTTPClientForGitHub, this does not fall back to keyring.
func getHTTPClientForGitHubWithToken(ctx context.Context, token string) *http.Client {
if token == "" {
return http.DefaultClient
}
return oauth2.NewClient(ctx, oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
))
}
07070100000067000081A4000000000000000000000001693FE2CD00000041000000000000000000000000000000000000002300000000pinact-3.6.0/pkg/github/keyring.gopackage github
const (
KeyService = "suzuki-shunsuke/pinact"
)
07070100000068000081A4000000000000000000000001693FE2CD00000453000000000000000000000000000000000000002400000000pinact-3.6.0/pkg/github/registry.gopackage github
import (
"context"
"fmt"
"github.com/suzuki-shunsuke/pinact/v3/pkg/config"
)
// ClientRegistry manages GitHub clients for github.com and a GHES instance.
// It maintains a default client for github.com and optionally a client
// for a configured GHES instance.
type ClientRegistry struct {
defaultClient *Client
ghesClient *Client
ghesConfig *config.GHES
}
// NewClientRegistry creates a new ClientRegistry with clients for github.com
// and optionally a GHES instance.
func NewClientRegistry(ctx context.Context, defaultClient *Client, ghes *config.GHES, token string) (*ClientRegistry, error) {
registry := &ClientRegistry{
defaultClient: defaultClient,
ghesConfig: ghes,
}
if ghes != nil {
client, err := NewWithBaseURL(ctx, ghes.APIURL, token)
if err != nil {
return nil, fmt.Errorf("create GHES client for %s: %w", ghes.APIURL, err)
}
registry.ghesClient = client
}
return registry, nil
}
// GetGHESClient returns the GHES client if configured, or nil if not configured.
func (r *ClientRegistry) GetGHESClient() *Client {
return r.ghesClient
}
07070100000069000081A4000000000000000000000001693FE2CD00002F12000000000000000000000000000000000000002300000000pinact-3.6.0/pkg/github/service.gopackage github
import (
"context"
"fmt"
"log/slog"
"net/http"
"github.com/google/go-github/v80/github"
)
// RepositoriesService defines the interface for GitHub Repositories API operations.
type RepositoriesService interface {
ListTags(ctx context.Context, owner string, repo string, opts *ListOptions) ([]*RepositoryTag, *Response, error)
GetCommitSHA1(ctx context.Context, owner, repo, ref, lastSHA string) (string, *Response, error)
ListReleases(ctx context.Context, owner, repo string, opts *ListOptions) ([]*RepositoryRelease, *Response, error)
Get(ctx context.Context, owner, repo string) (*Repository, *Response, error)
}
// PullRequestsService defines the interface for GitHub Pull Requests API operations.
type PullRequestsService interface {
CreateComment(ctx context.Context, owner, repo string, number int, comment *PullRequestComment) (*PullRequestComment, *Response, error)
}
// GitService defines the interface for GitHub Git API operations.
type GitService interface {
GetCommit(ctx context.Context, owner, repo, sha string) (*Commit, *Response, error)
}
// repoHost represents which GitHub host a repository belongs to.
type repoHost int
const (
repoHostUnknown repoHost = iota
repoHostGHES
repoHostGitHubDotCom
)
// ClientResolver resolves which GitHub service (GHES or github.com) to use for a given repository.
// It uses the Get a Repository API to check if a repository exists on GHES or github.com,
// and caches the result to avoid redundant API calls.
type ClientResolver struct {
defaultRepoService RepositoriesService
defaultGitService GitService
ghesRepoService RepositoriesService
ghesGitService GitService
// repoHosts caches which host a repository belongs to
repoHosts map[string]repoHost
// fallback controls whether to fallback to github.com when a repository is not found on GHES
fallback bool
}
// NewClientResolver creates a new ClientResolver with the given services.
func NewClientResolver(
defaultRepoService RepositoriesService,
defaultGitService GitService,
ghesRepoService RepositoriesService,
ghesGitService GitService,
fallback bool,
) *ClientResolver {
return &ClientResolver{
defaultRepoService: defaultRepoService,
defaultGitService: defaultGitService,
ghesRepoService: ghesRepoService,
ghesGitService: ghesGitService,
fallback: fallback,
repoHosts: map[string]repoHost{},
}
}
// GetRepositoriesService returns the appropriate RepositoriesService for the given repository.
func (r *ClientResolver) GetRepositoriesService(ctx context.Context, logger *slog.Logger, owner, repo string) (RepositoriesService, error) {
host, err := r.resolveRepoHost(ctx, logger, owner, repo)
if err != nil {
return nil, err
}
if host == repoHostGitHubDotCom {
return r.defaultRepoService, nil
}
return r.ghesRepoService, nil
}
// GetGitService returns the appropriate GitService for the given repository.
func (r *ClientResolver) GetGitService(ctx context.Context, logger *slog.Logger, owner, repo string) (GitService, error) {
host, err := r.resolveRepoHost(ctx, logger, owner, repo)
if err != nil {
return nil, err
}
if host == repoHostGitHubDotCom {
return r.defaultGitService, nil
}
return r.ghesGitService, nil
}
// resolveRepoHost determines which host a repository belongs to using the Get a Repository API.
// If fallback is disabled, it always uses GHES without checking repository existence.
// If fallback is enabled, it checks GHES first and falls back to github.com if not found.
func (r *ClientResolver) resolveRepoHost(ctx context.Context, logger *slog.Logger, owner, repo string) (repoHost, error) {
// If GHES is not configured, use github.com
if r.ghesRepoService == nil {
return repoHostGitHubDotCom, nil
}
// If fallback is disabled, always use GHES without checking
if !r.fallback {
return repoHostGHES, nil
}
key := owner + "/" + repo
// Check cache first
if host, ok := r.repoHosts[key]; ok {
return host, nil
}
// Fallback is enabled: check if repository exists on GHES
_, resp, err := r.ghesRepoService.Get(ctx, owner, repo)
if err == nil {
logger.Debug("repository found on GHES", "owner", owner, "repo", repo)
r.repoHosts[key] = repoHostGHES
return repoHostGHES, nil
}
// If GHES returned 404, check github.com
if resp != nil && resp.StatusCode == http.StatusNotFound {
_, resp, err = r.defaultRepoService.Get(ctx, owner, repo)
if err == nil {
logger.Debug("repository found on github.com (fallback)", "owner", owner, "repo", repo)
r.repoHosts[key] = repoHostGitHubDotCom
return repoHostGitHubDotCom, nil
}
// Repository not found on either host
if resp != nil && resp.StatusCode == http.StatusNotFound {
return repoHostUnknown, fmt.Errorf("repository %s/%s not found on GHES or github.com", owner, repo)
}
}
// Other error from GHES or github.com
return repoHostUnknown, fmt.Errorf("failed to check repository %s/%s: %w", owner, repo, err)
}
// GitServiceImpl wraps a GitService with caching and GHES fallback support.
type GitServiceImpl struct {
resolver *ClientResolver
Commits map[string]*GetCommitResult
}
// SetResolver sets the ClientResolver for the GitServiceImpl.
func (g *GitServiceImpl) SetResolver(resolver *ClientResolver) {
g.resolver = resolver
}
// GetCommitResult holds the cached result of a GetCommit call.
type GetCommitResult struct {
Commit *Commit
Response *Response
err error
}
// GetCommit retrieves a commit object with caching and GHES fallback.
func (g *GitServiceImpl) GetCommit(ctx context.Context, logger *slog.Logger, owner, repo, sha string) (*Commit, *Response, error) {
key := fmt.Sprintf("%s/%s/%s", owner, repo, sha)
if result, ok := g.Commits[key]; ok {
return result.Commit, result.Response, result.err
}
commit, resp, err := g.getCommit(ctx, logger, owner, repo, sha)
g.Commits[key] = &GetCommitResult{
Commit: commit,
Response: resp,
err: err,
}
return commit, resp, err
}
// getCommit calls the appropriate GitService based on the repository host.
func (g *GitServiceImpl) getCommit(ctx context.Context, logger *slog.Logger, owner, repo, sha string) (*Commit, *Response, error) {
service, err := g.resolver.GetGitService(ctx, logger, owner, repo)
if err != nil {
return nil, nil, err
}
return service.GetCommit(ctx, owner, repo, sha) //nolint:wrapcheck
}
// ListTagsResult holds the cached result of a ListTags call.
type ListTagsResult struct {
Tags []*RepositoryTag
Response *Response
err error
}
// ListReleasesResult holds the cached result of a ListReleases call.
type ListReleasesResult struct {
Releases []*RepositoryRelease
Response *Response
err error
}
// RepositoriesServiceImpl wraps a RepositoriesService with caching and GHES fallback support.
type RepositoriesServiceImpl struct {
resolver *ClientResolver
Tags map[string]*ListTagsResult
Commits map[string]*GetCommitSHA1Result
Releases map[string]*ListReleasesResult
}
// SetResolver sets the ClientResolver for the RepositoriesServiceImpl.
func (r *RepositoriesServiceImpl) SetResolver(resolver *ClientResolver) {
r.resolver = resolver
}
// Get fetches a repository to check its existence.
func (r *RepositoriesServiceImpl) Get(ctx context.Context, logger *slog.Logger, owner, repo string) (*Repository, *Response, error) {
service, err := r.resolver.GetRepositoriesService(ctx, logger, owner, repo)
if err != nil {
return nil, nil, err
}
return service.Get(ctx, owner, repo) //nolint:wrapcheck
}
// GetCommitSHA1 retrieves the commit SHA for a given reference with caching and GHES fallback.
func (r *RepositoriesServiceImpl) GetCommitSHA1(ctx context.Context, logger *slog.Logger, owner, repo, ref, lastSHA string) (string, *Response, error) {
key := fmt.Sprintf("%s/%s/%s", owner, repo, ref)
if result, ok := r.Commits[key]; ok {
return result.SHA, result.Response, result.err
}
sha, resp, err := r.getCommitSHA1(ctx, logger, owner, repo, ref, lastSHA)
r.Commits[key] = &GetCommitSHA1Result{
SHA: sha,
Response: resp,
err: err,
}
return sha, resp, err
}
// GetCommitSHA1Result holds the cached result of a GetCommitSHA1 call.
type GetCommitSHA1Result struct {
SHA string
Response *Response
err error
}
// ListTags retrieves repository tags with caching and GHES fallback.
func (r *RepositoriesServiceImpl) ListTags(ctx context.Context, logger *slog.Logger, owner string, repo string, opts *ListOptions) ([]*RepositoryTag, *Response, error) {
key := fmt.Sprintf("%s/%s/%v", owner, repo, opts.Page)
if result, ok := r.Tags[key]; ok {
return result.Tags, result.Response, result.err
}
tags, resp, err := r.listTags(ctx, logger, owner, repo, opts)
r.Tags[key] = &ListTagsResult{
Tags: tags,
Response: resp,
err: err,
}
return tags, resp, err
}
// ListReleases retrieves repository releases with caching and GHES fallback.
func (r *RepositoriesServiceImpl) ListReleases(ctx context.Context, logger *slog.Logger, owner string, repo string, opts *ListOptions) ([]*RepositoryRelease, *Response, error) {
key := fmt.Sprintf("%s/%s/%v", owner, repo, opts.Page)
if result, ok := r.Releases[key]; ok {
return result.Releases, result.Response, result.err
}
releases, resp, err := r.listReleases(ctx, logger, owner, repo, opts)
arr := filterDraftReleases(releases)
r.Releases[key] = &ListReleasesResult{
Releases: arr,
Response: resp,
err: err,
}
return arr, resp, err
}
// getCommitSHA1 calls the appropriate RepositoriesService based on the repository host.
func (r *RepositoriesServiceImpl) getCommitSHA1(ctx context.Context, logger *slog.Logger, owner, repo, ref, lastSHA string) (string, *Response, error) {
service, err := r.resolver.GetRepositoriesService(ctx, logger, owner, repo)
if err != nil {
return "", nil, err
}
return service.GetCommitSHA1(ctx, owner, repo, ref, lastSHA) //nolint:wrapcheck
}
// listTags calls the appropriate RepositoriesService based on the repository host.
func (r *RepositoriesServiceImpl) listTags(ctx context.Context, logger *slog.Logger, owner string, repo string, opts *ListOptions) ([]*RepositoryTag, *Response, error) {
service, err := r.resolver.GetRepositoriesService(ctx, logger, owner, repo)
if err != nil {
return nil, nil, err
}
return service.ListTags(ctx, owner, repo, opts) //nolint:wrapcheck
}
// listReleases calls the appropriate RepositoriesService based on the repository host.
func (r *RepositoriesServiceImpl) listReleases(ctx context.Context, logger *slog.Logger, owner string, repo string, opts *ListOptions) ([]*RepositoryRelease, *Response, error) {
service, err := r.resolver.GetRepositoriesService(ctx, logger, owner, repo)
if err != nil {
return nil, nil, err
}
return service.ListReleases(ctx, owner, repo, opts) //nolint:wrapcheck
}
func filterDraftReleases(releases []*RepositoryRelease) []*RepositoryRelease {
arr := make([]*RepositoryRelease, 0, len(releases))
for _, release := range releases {
// Ignore draft releases
if release.GetDraft() {
continue
}
arr = append(arr, release)
}
return arr
}
// PullRequestsServiceImpl wraps PullRequestsService with GHES support.
type PullRequestsServiceImpl struct {
defaultPRService PullRequestsService
ghesPRService PullRequestsService
}
// SetServices sets the default and GHES PullRequestsService.
func (p *PullRequestsServiceImpl) SetServices(defaultService, ghesService PullRequestsService) {
p.defaultPRService = defaultService
p.ghesPRService = ghesService
}
// CreateComment creates a pull request comment.
// If GHES is enabled, it always uses GHES (no fallback).
func (p *PullRequestsServiceImpl) CreateComment(ctx context.Context, owner, repo string, number int, comment *github.PullRequestComment) (*github.PullRequestComment, *github.Response, error) {
if p.ghesPRService != nil {
return p.ghesPRService.CreateComment(ctx, owner, repo, number, comment) //nolint:wrapcheck
}
return p.defaultPRService.CreateComment(ctx, owner, repo, number, comment) //nolint:wrapcheck
}
0707010000006A000081A4000000000000000000000001693FE2CD00000169000000000000000000000000000000000000001C00000000pinact-3.6.0/renovate.json5{
extends: [
"github>suzuki-shunsuke/renovate-config#3.3.1",
"github>suzuki-shunsuke/renovate-config:nolimit#3.3.1",
"github>suzuki-shunsuke/renovate-config:go-directive#3.3.1",
"github>aquaproj/aqua-renovate-config#2.9.0",
"github>aquaproj/aqua-renovate-config:file#2.9.0(aqua/imports/.*\\.ya?ml)",
],
ignorePaths: ["testdata/**"],
}
0707010000006B000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001500000000pinact-3.6.0/scripts0707010000006C000081A4000000000000000000000001693FE2CD000001E2000000000000000000000000000000000000002100000000pinact-3.6.0/scripts/coverage.sh#!/usr/bin/env bash
set -eu
set -o pipefail
cd "$(dirname "$0")/.."
if [ $# -eq 0 ]; then
target="$(go list ./... | fzf)"
profile=.coverage/$target/coverage.txt
mkdir -p .coverage/"$target"
elif [ $# -eq 1 ]; then
target=$1
mkdir -p .coverage/"$target"
profile=.coverage/$target/coverage.txt
target=./$target
else
echo "too many arguments are given: $*" >&2
exit 1
fi
go test "$target" -coverprofile="$profile" -covermode=atomic
go tool cover -html="$profile"
0707010000006D000081A4000000000000000000000001693FE2CD000000CA000000000000000000000000000000000000002700000000pinact-3.6.0/scripts/generate-usage.sh#!/usr/bin/env bash
set -eu
cd "$(dirname "$0")/.."
help=$(pinact help-all)
echo "# Usage
<!-- This is generated by scripts/generate-usage.sh. Don't edit this file directly. -->
$help" > USAGE.md
0707010000006E000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001300000000pinact-3.6.0/specs0707010000006F000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001800000000pinact-3.6.0/specs/126507070100000070000081A4000000000000000000000001693FE2CD000006F4000000000000000000000000000000000000002200000000pinact-3.6.0/specs/1265/README.md# Spec: Add `--min-age` option to `pinact run`
- [#1265](https://github.com/suzuki-shunsuke/pinact/pull/1265)
- [#1266](https://github.com/suzuki-shunsuke/pinact/pull/1266)
- [#1267](https://github.com/suzuki-shunsuke/pinact/pull/1267)
## Overview
Add a `--min-age` (`-m`) option to `pinact run` that works in conjunction with the `-u` (update) option. This option filters update targets based on release age - only versions released more than the specified number of days ago will be considered for updates.
## Behavior
- When `--min-age N` is specified, versions released within the last N days are skipped
- Example: `pinact run -u --min-age 7` will exclude any versions released within the past 7 days from updates
- The `-u` option fetches GitHub Releases or tags via GitHub API; this feature checks the release/tag creation date against the min-age period
- When a version is skipped due to min-age, output a debug log message
## Validation Rules
- **Error**: If `--min-age` is specified without `-u` option
- **Error**: If `--min-age` is given a negative value
- **Default**: If `--min-age` is not specified or `--min-age 0`, all versions are eligible for update (existing behavior)
## Use Case
This feature helps avoid updating to recently released, potentially unstable versions, allowing users to update only to versions that have had time to prove their stability.
## Example Usage
```sh
pinact run -u --min-age 7
# or using the short alias
pinact run -u -m 7
```
## Date Fields Used
- **Releases**: Use `PublishedAt` field from GitHub API
- **Tags**: Use `Committer.Date` from the commit object (requires additional API call to `GET /repos/{owner}/{repo}/git/commits/{sha}`)
- If the commit cannot be fetched, the tag is skipped and a warning is logged
07070100000071000081A4000000000000000000000001693FE2CD000003B7000000000000000000000000000000000000002300000000pinact-3.6.0/specs/1265/history.md# History
1. 2025-12 `--cooldown` was renamed to `--min-age`
https://github.com/suzuki-shunsuke/pinact/issues/1265#issuecomment-3632228752
## Background
The `--cooldown` option was originally implemented in PR #1266.
However, the option name was renamed to `--min-age` with alias `-m` due to the following reasons:
1. **Alias conflict**: The short alias `-c` was already used by `--config` option, so `--cooldown` could not have a convenient short alias.
2. **Desire for short alias**: Since `cooldown` is a relatively long option name, a short alias was desired for usability.
3. **Semantic clarity**: `--min-age` better describes the option's behavior - it specifies the minimum age (in days) that a version must have to be considered for updates.
### Changes Made
- Renamed `--cooldown` to `--min-age`
- Added `-m` as a short alias
- Updated all internal variable names from `Cooldown` to `MinAge`
- Updated documentation and error messages
07070100000072000081A4000000000000000000000001693FE2CD00000C17000000000000000000000000000000000000002000000000pinact-3.6.0/specs/1265/plan.md# Implementation Plan: Add `--min-age` option to `pinact run`
## Files to Modify
### 1. `pkg/cli/run/command.go`
- Add `MinAge int` field to `Flags` struct
- Add `--min-age` IntFlag definition with `Validator` and `-m` alias
- Add validation in `action()` method for flag combination
### 2. `pkg/controller/run/run.go`
- Add `MinAge int` field to `ParamRun` struct
### 3. `pkg/controller/run/github.go`
- Add `GitService` interface for getting commits
- Modify `getLatestVersionFromReleases()` to filter releases by `PublishedAt` date
- Modify `getLatestVersionFromTags()` to filter tags by commit date
- Add debug logging when a version is skipped due to min-age
## Implementation Details
### CLI Flag Definition
```go
&cli.IntFlag{
Name: "min-age",
Aliases: []string{"m"},
Usage: "Skip versions released within the specified number of days (requires -u)",
Destination: &flags.MinAge,
Validator: func(i int) error {
if i < 0 {
return errors.New("--min-age must be a non-negative integer")
}
return nil
},
},
```
### Validation Logic (in `action()`)
```go
if flags.MinAge > 0 && !flags.Update {
return errors.New("--min-age requires --update (-u) flag")
}
```
### MinAge Cutoff Calculation
Calculate `cutoff` once in `getLatestVersion()` and pass to filtering functions:
```go
func (c *Controller) getLatestVersion(ctx context.Context, logger *slog.Logger, owner, repo, currentVersion string) (string, error) {
isStable := isStableVersion(currentVersion)
// Calculate cutoff once for min-age filtering
var cutoff time.Time
if c.param.MinAge > 0 {
cutoff = time.Now().AddDate(0, 0, -c.param.MinAge)
}
lv, err := c.getLatestVersionFromReleases(ctx, logger, owner, repo, isStable, cutoff)
// ...
return c.getLatestVersionFromTags(ctx, logger, owner, repo, isStable, cutoff)
}
```
### Release Filtering Logic
```go
// cutoff is passed as parameter (zero value means no filtering)
if !cutoff.IsZero() && release.GetPublishedAt().Time.After(cutoff) {
logger.Debug("skip release due to min-age",
slog.String("tag", tag),
slog.Time("published_at", release.GetPublishedAt().Time))
continue
}
```
### Tag Handling
- Use `GitService.GetCommit(ctx, owner, repo, sha)` to get commit info
- Check `commit.GetCommitter().GetDate().Time` against `cutoff` parameter
- Cache results to avoid redundant API calls
## Execution Order
1. Add `MinAge int` field to `Flags` struct in `pkg/cli/run/command.go`
2. Add `--min-age` IntFlag definition with `Validator` and `-m` alias
3. Add validation in `action()` method for flag combination
4. Add `MinAge int` field to `ParamRun` struct in `pkg/controller/run/run.go`
5. Pass `MinAge` to `ParamRun` in `action()` method
6. Add `GitService` interface to `pkg/controller/run/github.go`
7. Implement min-age filtering in `getLatestVersionFromReleases()`
8. Implement min-age filtering in `getLatestVersionFromTags()`
9. Wire up `gh.Git` to controller in `action()` method
10. Run `cmdx v` and `cmdx t` to validate
07070100000073000081A4000000000000000000000001693FE2CD00000B99000000000000000000000000000000000000002000000000pinact-3.6.0/specs/1265/test.md# Test Plan: `--min-age` option
## Validation Tests
### 1. Error when `--min-age` is specified without `-u`
```sh
pinact run --min-age 7
```
**Expected**: Error message indicating `--min-age requires --update (-u) flag`
### 2. Error when `--min-age` is negative
```sh
pinact run -u --min-age -1
```
**Expected**: Error message indicating `--min-age must be a non-negative integer`
### 3. `--min-age 0` should work (no filtering)
```sh
pinact run -u --min-age 0
```
**Expected**: Behaves same as `pinact run -u` (all versions eligible)
## Functional Tests
### 4. Skip recently released versions (releases)
Test with a repository that has GitHub Releases.
```sh
# Use a large min-age to skip recent releases
pinact run -u --min-age 9999 --log-level info
# or using short alias
pinact run -u -m 9999 --log-level info
```
**Expected**:
- Info log messages like `skip release due to cooldown` with tag and published_at
- Action not updated to the latest version
### 5. Skip recently released versions (tags)
Test with a repository that only uses tags (no releases).
```sh
pinact run -u --min-age 9999 --log-level info
```
**Expected**:
- Info log messages like `skip tag due to cooldown` with tag and committed_at
- Action not updated to the latest version
### 6. Update to eligible version
```sh
# Use small min-age that allows some versions
pinact run -u --min-age 30 --log-level info
```
**Expected**:
- Recent versions skipped (info logs)
- Action updated to the latest eligible version (older than 30 days)
### 7. No update when all versions are within min-age
```sh
# Test with a very new action or large min-age
pinact run -u --min-age 9999
```
**Expected**: No changes to the file (current version retained)
## Edge Cases
### 8. Mixed releases and tags
Test with a repository that has both releases and tags with different dates.
**Expected**: Min-age filtering applied consistently
### 9. Commit fetch failure for tags
Test scenario where commit cannot be fetched (e.g., deleted commit, API error).
```sh
pinact run -u --min-age 7 --log-level warn
```
**Expected**:
- Warning log: `skip tag: failed to get commit for cooldown check`
- That tag is skipped
### 10. Stable version filtering combined with min-age
Test with a prerelease current version and stable version:
```yaml
- uses: owner/repo@sha # v1.0.0-beta
```
```sh
pinact run -u --min-age 30
```
**Expected**: Both prerelease filtering and min-age filtering applied
## Test Workflow Files
### Sample workflow for testing
`.github/workflows/test.yaml`:
```yaml
name: Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
# Test with actions/checkout (has releases)
- uses: actions/checkout@v4
# Test with an action that only has tags
- uses: suzuki-shunsuke/tfcmt@v1
```
Run:
```sh
pinact run -u --min-age 7 .github/workflows/test.yaml --log-level info
# or
pinact run -u -m 7 .github/workflows/test.yaml --log-level info
```
07070100000074000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001700000000pinact-3.6.0/specs/83907070100000075000081A4000000000000000000000001693FE2CD00001AFC000000000000000000000000000000000000002100000000pinact-3.6.0/specs/839/README.md# Spec: GitHub Enterprise Server (GHES) Support
- [#839](https://github.com/suzuki-shunsuke/pinact/issues/839)
- [GitHub Docs: Enabling automatic access to github.com actions using GitHub Connect](https://docs.github.com/en/enterprise-server@3.19/admin/managing-github-actions-for-your-enterprise/managing-access-to-actions-from-githubcom/enabling-automatic-access-to-githubcom-actions-using-github-connect)
## Overview
Add support for pinning and updating GitHub Actions hosted on GitHub Enterprise Server (GHES). When GitHub Connect is enabled, actions from github.com are also supported alongside GHES-hosted actions.
## Configuration
### Configuration File
GHES settings are defined in the configuration file (`.pinact.yaml`):
```yaml
ghes:
api_url: https://ghes.example.com # /api/v3/ is appended if not present
fallback: false # Whether to fallback to github.com (default: false)
```
- `api_url` (optional): The API URL of the GHES instance (e.g., `https://ghes.example.com`). Can also be set via environment variables.
- `fallback` (optional): Whether to fallback to github.com when a repository is not found on GHES (default: `false`)
### Environment Variables
#### GHES Configuration
GHES can also be configured via environment variables:
- `PINACT_GHES_API_URL`: GHES API URL (e.g., `https://ghes.example.com`)
- `GITHUB_API_URL`: Alternative to `PINACT_GHES_API_URL` (commonly set in GitHub Actions on GHES)
- `PINACT_GHES_FALLBACK`: Set to `true` to enable fallback to github.com
```bash
export PINACT_GHES_API_URL="https://ghes.example.com"
export PINACT_GHES_FALLBACK="true" # optional: enable fallback
```
Resolution priority for API URL:
1. If `PINACT_GHES_API_URL` is set, it is used (and `GITHUB_API_URL` is ignored)
2. If `PINACT_GHES_API_URL` is not set but `GITHUB_API_URL` is set and is not `https://api.github.com`, `GITHUB_API_URL` is used
#### Conditions for Enabling GHES
GHES mode is enabled when any of the following conditions are met:
1. `ghes.api_url` is configured in the configuration file
2. `PINACT_GHES_API_URL` environment variable is set
3. `GITHUB_API_URL` environment variable is set and is not `https://api.github.com`
Environment variables can also complement missing values in the configuration file:
- If `ghes.api_url` is empty in the config file, it is filled from `PINACT_GHES_API_URL` or `GITHUB_API_URL`
- `PINACT_GHES_FALLBACK=true` can enable fallback even if not set in config file
This allows using GHES without a configuration file.
#### GitHub Access Tokens
GitHub Access Tokens are specified via environment variables:
- `GITHUB_TOKEN`: Token for github.com (existing behavior)
- GHES token (checked in order, first non-empty value is used):
1. `GHES_TOKEN`
2. `GITHUB_TOKEN_ENTERPRISE`
3. `GITHUB_ENTERPRISE_TOKEN`
## Behavior
1. pinact parses workflow files and extracts actions (existing behavior)
2. For each extracted action:
- If GHES is not enabled, search on github.com (existing behavior)
- If GHES is enabled and `fallback: false` (default), always use GHES
- If GHES is enabled and `fallback: true`, check GHES first, then fallback to github.com if not found
### Fallback Behavior
By default, fallback is **disabled** (`fallback: false`). This aligns with GHES default behavior where GitHub Connect is disabled.
#### When `fallback: false` (default)
- All actions are searched on the GHES instance only
- No repository existence check is performed (better performance)
- If the action is not found on GHES, an error is returned
#### When `fallback: true`
- Actions are first searched on the GHES instance using the Get a Repository API
- If GHES returns 404 (not found), the action is searched on github.com
- The result is cached per repository to avoid redundant API calls
- Other errors from GHES are returned without fallback
This approach eliminates the need to maintain a list of owners and simplifies configuration. Users only need to configure the GHES API URL and optionally enable fallback.
### Review Mode (`pinact run -review`)
When using `pinact run -review`, the review comment is created on the GHES instance if GHES is enabled, otherwise on github.com. There is no fallback for PR comments - if GHES is enabled but the comment creation fails, an error is returned.
## Constraints
- Only one GHES instance is supported
- Fallback only applies to action searches, not to PR comment creation
- Fallback is disabled by default for security and consistency with GHES defaults
## Example
### Using Configuration File (without fallback)
```yaml
# .pinact.yaml
ghes:
api_url: https://ghes.example.com
```
```bash
export GHES_TOKEN="ghp_yyyy" # for GHES
```
### Using Configuration File (with fallback)
```yaml
# .pinact.yaml
ghes:
api_url: https://ghes.example.com
fallback: true
```
```bash
export GITHUB_TOKEN="ghp_xxxx" # for github.com
export GHES_TOKEN="ghp_yyyy" # for GHES
```
### Using Environment Variables Only
```bash
export GHES_TOKEN="ghp_yyyy" # for GHES
export PINACT_GHES_API_URL="https://ghes.example.com"
# export PINACT_GHES_FALLBACK="true" # optional: enable fallback
# export GITHUB_TOKEN="ghp_xxxx" # required if fallback is enabled
```
### Using GITHUB_API_URL (GitHub Actions on GHES)
[When running on GitHub Actions hosted on GHES, `GITHUB_API_URL` is automatically set](https://docs.github.com/en/enterprise-server@3.19/actions/reference/workflows-and-actions/variables), so `PINACT_GHES_API_URL` is not required:
```bash
# GITHUB_API_URL is automatically set by GitHub Actions on GHES
# GITHUB_API_URL="https://ghes.example.com/api/v3"
export GHES_TOKEN="ghp_yyyy" # for GHES
# export PINACT_GHES_FALLBACK="true" # optional: enable fallback
# export GITHUB_TOKEN="ghp_xxxx" # required if fallback is enabled
```
### Workflow
```yaml
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
# When fallback is disabled (default): always use GHES
# When fallback is enabled: check GHES first, fallback to github.com if not found
- uses: my-org/build-action@v1
# Same behavior for all actions
- uses: actions/checkout@v4
```
## API Integration
For GHES instances, pinact uses the same GitHub API endpoints but with the GHES API URL:
- API URL: `<api_url>/api/v3/` (appended automatically if not present)
- Authentication: Bearer token via GHES token environment variables
## Error Handling
- If GHES is enabled but the GHES token is not found, return an error with a clear message
- If the GHES API request fails with non-404 error, return the error without fallback to github.com
- Missing `api_url` when GHES is enabled should be reported at startup
## Logging
- Fallback from GHES to github.com is logged at debug level to avoid noisy output (only when `fallback: true`)
- This allows users to understand the behavior without being overwhelmed by logs
07070100000076000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001600000000pinact-3.6.0/testdata07070100000077000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001E00000000pinact-3.6.0/testdata/actions07070100000078000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002400000000pinact-3.6.0/testdata/actions.after07070100000079000081A4000000000000000000000001693FE2CD0000008E000000000000000000000000000000000000003000000000pinact-3.6.0/testdata/actions.after/action.yamlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
0707010000007A000081A4000000000000000000000001693FE2CD0000008E000000000000000000000000000000000000002F00000000pinact-3.6.0/testdata/actions.after/action.ymlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
0707010000007B000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002800000000pinact-3.6.0/testdata/actions.after/foo0707010000007C000081A4000000000000000000000001693FE2CD0000008E000000000000000000000000000000000000003400000000pinact-3.6.0/testdata/actions.after/foo/action.yamlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
0707010000007D000081A4000000000000000000000001693FE2CD0000008E000000000000000000000000000000000000003300000000pinact-3.6.0/testdata/actions.after/foo/action.ymlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
0707010000007E000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002C00000000pinact-3.6.0/testdata/actions.after/foo/bar0707010000007F000081A4000000000000000000000001693FE2CD0000008E000000000000000000000000000000000000003800000000pinact-3.6.0/testdata/actions.after/foo/bar/action.yamlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
07070100000080000081A4000000000000000000000001693FE2CD0000008E000000000000000000000000000000000000003700000000pinact-3.6.0/testdata/actions.after/foo/bar/action.ymlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
07070100000081000081A4000000000000000000000001693FE2CD0000008A000000000000000000000000000000000000002A00000000pinact-3.6.0/testdata/actions/action.yamlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
07070100000082000081A4000000000000000000000001693FE2CD0000008A000000000000000000000000000000000000002900000000pinact-3.6.0/testdata/actions/action.ymlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
07070100000083000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002200000000pinact-3.6.0/testdata/actions/foo07070100000084000081A4000000000000000000000001693FE2CD0000008A000000000000000000000000000000000000002E00000000pinact-3.6.0/testdata/actions/foo/action.yamlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
07070100000085000081A4000000000000000000000001693FE2CD0000008A000000000000000000000000000000000000002D00000000pinact-3.6.0/testdata/actions/foo/action.ymlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
07070100000086000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000002600000000pinact-3.6.0/testdata/actions/foo/bar07070100000087000081A4000000000000000000000001693FE2CD0000008A000000000000000000000000000000000000003200000000pinact-3.6.0/testdata/actions/foo/bar/action.yamlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
07070100000088000081A4000000000000000000000001693FE2CD0000008A000000000000000000000000000000000000003100000000pinact-3.6.0/testdata/actions/foo/bar/action.ymlname: Test
description: Test
runs:
using: composite
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
07070100000089000081A4000000000000000000000001693FE2CD000002A9000000000000000000000000000000000000001F00000000pinact-3.6.0/testdata/bar.yaml---
name: bar
on: workflow_call
jobs:
integration-test:
runs-on: ubuntu-latest
permissions: {}
steps:
# The version annotation is "v3.5.1", so you would think the version of the action is v3.5.1.
# But the actual version is v2.7.0 because "ee0669bd1cc54295c223e0bb666b733df41de1c5" is the commit hash of v2.7.0.
# https://github.com/actions/checkout/releases/tag/v3.5.1
# https://github.com/actions/checkout/releases/tag/v2.7.0
# This means version annotations aren't necessarily correct.
# pinact run's --verify option verifies version annotations.
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v3.5.1
0707010000008A000081A4000000000000000000000001693FE2CD000004B4000000000000000000000000000000000000001F00000000pinact-3.6.0/testdata/foo.yaml---
name: foo
on: workflow_call
jobs:
integration-test:
runs-on: ubuntu-latest
permissions: {}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3
- uses: actions/checkout@83b7061638ee4956cf7545a6f7efe594e5ad0247
- uses: actions/checkout@v2
- uses: actions/cache@v3.3.1
- uses: actions/setup-java@v3
- uses: rharkor/caching-for-turbo@v1.6
- uses: peaceiris/actions-gh-pages@v4.0.0
- 'uses': "actions/checkout@v3"
- "uses": 'actions/checkout@v3'
- uses: cardinalby/git-get-release-action@cf4593dd18e51a1ecfbfb1c68abac9910a8b1e0c # v1
actionlint:
uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@v0.5.0
with:
aqua_version: v2.3.4
permissions:
pull-requests: write
contents: read
slsa-github-generator:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.6.0
0707010000008B000081A4000000000000000000000001693FE2CD000005D2000000000000000000000000000000000000002500000000pinact-3.6.0/testdata/foo_after.yaml---
name: foo
on: workflow_call
jobs:
integration-test:
runs-on: ubuntu-latest
permissions: {}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3.5.3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3.5.3
- uses: actions/checkout@83b7061638ee4956cf7545a6f7efe594e5ad0247
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
- uses: actions/setup-java@v3
- uses: rharkor/caching-for-turbo@c3de885e542eec7eb01eb1a6a59e97c7a2448615 # v1.6
- uses: peaceiris/actions-gh-pages@v4.0.0
- 'uses': "actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744" # v3.6.0
- "uses": 'actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744' # v3.6.0
- uses: cardinalby/git-get-release-action@cf4593dd18e51a1ecfbfb1c68abac9910a8b1e0c # v1
actionlint:
uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@b6a5f966d4504893b2aeb60cf2b0de8946e48504 # v0.5.0
with:
aqua_version: v2.3.4
permissions:
pull-requests: write
contents: read
slsa-github-generator:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.6.0
0707010000008C000081A4000000000000000000000001693FE2CD00000535000000000000000000000000000000000000002D00000000pinact-3.6.0/testdata/foo_exclude_after.yaml---
name: foo
on: workflow_call
jobs:
integration-test:
runs-on: ubuntu-latest
permissions: {}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3
- uses: actions/checkout@83b7061638ee4956cf7545a6f7efe594e5ad0247
- uses: actions/checkout@v2
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
- uses: actions/setup-java@v3
- uses: rharkor/caching-for-turbo@c3de885e542eec7eb01eb1a6a59e97c7a2448615 # v1.6
- uses: peaceiris/actions-gh-pages@v4.0.0
- 'uses': "actions/checkout@v3"
- "uses": 'actions/checkout@v3'
- uses: cardinalby/git-get-release-action@cf4593dd18e51a1ecfbfb1c68abac9910a8b1e0c # v1
actionlint:
uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@b6a5f966d4504893b2aeb60cf2b0de8946e48504 # v0.5.0
with:
aqua_version: v2.3.4
permissions:
pull-requests: write
contents: read
slsa-github-generator:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.6.0
0707010000008D000081A4000000000000000000000001693FE2CD00000551000000000000000000000000000000000000002D00000000pinact-3.6.0/testdata/foo_include_after.yaml---
name: foo
on: workflow_call
jobs:
integration-test:
runs-on: ubuntu-latest
permissions: {}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3.5.3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # tag=v3.5.3
- uses: actions/checkout@83b7061638ee4956cf7545a6f7efe594e5ad0247
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- uses: actions/cache@v3.3.1
- uses: actions/setup-java@v3
- uses: rharkor/caching-for-turbo@v1.6
- uses: peaceiris/actions-gh-pages@v4.0.0
- 'uses': "actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744" # v3.6.0
- "uses": 'actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744' # v3.6.0
- uses: cardinalby/git-get-release-action@cf4593dd18e51a1ecfbfb1c68abac9910a8b1e0c # v1
actionlint:
uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@v0.5.0
with:
aqua_version: v2.3.4
permissions:
pull-requests: write
contents: read
slsa-github-generator:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.6.0
0707010000008E000041ED000000000000000000000002693FE2CD00000000000000000000000000000000000000000000001E00000000pinact-3.6.0/testdata/migrate0707010000008F000081A4000000000000000000000001693FE2CD00000116000000000000000000000000000000000000002B00000000pinact-3.6.0/testdata/migrate/.pinact.yaml# yaml-language-server: $schema=../../json-schema/pinact.json
# pinact - https://github.com/suzuki-shunsuke/pinact
ignore_actions:
- name: actions/setup-java
- name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml
- name: peaceiris/.*
07070100000090000081A4000000000000000000000001693FE2CD00000145000000000000000000000000000000000000003000000000pinact-3.6.0/testdata/migrate/pinact.yaml.after# yaml-language-server: $schema=../../json-schema/pinact.json
# pinact - https://github.com/suzuki-shunsuke/pinact
ignore_actions:
- name: actions/setup-java
ref: .*
- name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml
ref: .*
- name: peaceiris/.*
ref: .*
version: 3
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!569 blocks