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

[![DeepWiki](https://img.shields.io/badge/DeepWiki-suzuki--shunsuke%2Fpinact-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](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:

![review](https://github.com/user-attachments/assets/77e78d23-bd14-49ba-8097-751556fcf126)

## :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

![review](https://github.com/user-attachments/assets/77e78d23-bd14-49ba-8097-751556fcf126)

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
> ```

![error-message-log](https://github.com/user-attachments/assets/0231dee4-4473-459b-8ea4-e4c6a1f417c8)

### 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
openSUSE Build Service is sponsored by