github-actions-maintainer 0.6.0

General-purpose GitHub Actions maintenance toolkit with secure workflow pinning
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# GitHub Actions Maintainer

[![CI](https://github.com/ThreatFlux/github-actions-maintainer/actions/workflows/ci.yml/badge.svg)](https://github.com/ThreatFlux/github_actions/actions/workflows/ci.yml)
[![Security](https://github.com/ThreatFlux/github-actions-maintainer/actions/workflows/security.yml/badge.svg)](https://github.com/ThreatFlux/github_actions/actions/workflows/security.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.97.1-orange.svg)](https://www.rust-lang.org)

General-purpose dependency maintenance in Rust, built from the ThreatFlux Rust CI/CD template. The shipped capabilities cover secure GitHub Action pinning plus latest-version reporting and updates for both GitHub Actions and cargo packages.

## What It Does

`github-actions-maintainer pin` scans workflow files, finds floating GitHub Action refs such as:

```yaml
- uses: actions/checkout@v4
```

and rewrites them to immutable commit SHAs while keeping the original ref as a comment:

```yaml
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v4
```

That preserves operator intent while eliminating runtime drift from moving tags and branches.

## Current Features

- Pins the ref already declared in the workflow instead of upgrading to a newer major automatically
- Supports GitHub-hosted actions with nested paths such as `github/codeql-action/init@v3`
- Skips local actions, Docker actions, and dynamic expressions like `${{ matrix.action }}`
- Offers `--dry-run` for previewing rewrites before touching files
- Can update actions to the latest GitHub release, with tag fallback when releases are absent
- Can report current tracked versions versus latest upstream versions without rewriting files
- Can scan `Cargo.toml` manifests, find the latest stable crates.io versions, and update supported cargo dependency requirements
- Reports unmanaged cargo dependency shapes such as `path`, `git`, and `workspace = true` entries instead of rewriting them
- Can create a remote branch and pull request with labels instead of rewriting the checked-out repo
- Retries GitHub API calls with exponential backoff and respects `Retry-After` plus rate-limit reset headers
- Validates token scopes before remote PR creation so missing `repo` or `workflow` permissions fail early

## CLI

```bash
cargo run -- pin --dry-run
cargo run -- update --dry-run
cargo run -- update --cargo --dry-run
cargo run -- update --all
cargo run -- update
cargo run -- status
cargo run -- status --cargo
cargo run -- pin --repo /path/to/repo --workflows-path .github/workflows
```

Options:

- `--repo`: repository root to scan, defaults to `.`
- `--workflows-path`: relative workflow directory, defaults to `.github/workflows`
- `--token`: optional GitHub token, also read from `GITHUB_TOKEN`
- `--dry-run`: report rewrites without applying them
- `--cargo`: target cargo package dependencies
- `--github-actions`: target GitHub Actions updates explicitly
- `--all`: target both GitHub Actions and cargo package dependencies
- `--create-pr`: create a remote branch and pull request instead of editing files locally
- `--owner` and `--repo-name`: remote repository coordinates for PR creation
- `--labels`, `--title`, `--commit-message`, `--base-branch`, `--branch-name`: control remote PR creation

Command behavior:

- `pin`: pin the ref already declared in the workflow
- `update`: move selected dependencies to the latest upstream version. By default it targets GitHub Actions; add `--cargo` or `--all` for cargo support
- `status`: report current tracked versions, latest upstream versions, and whether a change is needed for the selected target set
- `update` without `--create-pr`: apply changes locally in the checked-out repository, which is the equivalent of the original tool's stage mode
- `release`: bump the Cargo version from conventional commits, then create the release commit, tag, and GitHub Release through the API (see below)

Cargo update support currently manages registry-backed dependencies that declare a direct version requirement such as:

- `reqwest = "0.12.13"`
- `serde = { version = "^1.0.200", features = ["derive"] }`
- `regex = { version = "~1.10.0" }`

The updater preserves the existing requirement operator where possible and skips unsupported forms such as multi-range requirements, `path` dependencies, `git` dependencies, and `workspace = true` references.

Remote update mode:

```bash
cargo run -- update \
  --cargo \
  --create-pr \
  --owner ThreatFlux \
  --repo-name githubWorkFlowChecker \
  --token "$GITHUB_TOKEN"
```

Remote update mode will:

- validate the token before mutating repository state
- resolve the default branch when `--base-branch` is not provided
- create a tree/commit/branch through the GitHub API
- open a pull request and attach any requested labels

Release mode:

```bash
# Outside GitHub Actions, pass --owner/--repo-name (or set GITHUB_REPOSITORY).
cargo run -- release --dry-run \
  --owner ThreatFlux \
  --repo-name github_actions \
  --token "$GITHUB_TOKEN"
cargo run -- release \
  --owner ThreatFlux \
  --repo-name github_actions \
  --update-major-alias \
  --token "$GITHUB_TOKEN"
```

`release` runs entirely through the GitHub REST API — no `git`, `gh`, or `cargo` binaries are needed at runtime — so it works inside minimal containers:

1. Reads the current version from `Cargo.toml` (`[package].version`, falling back to `[workspace.package].version`).
2. Finds the latest `--tag-prefix` semver tag and classifies the conventional commits since it (`feat:` → minor, `fix:` → patch, `!`/`BREAKING CHANGE` → major). Merge commits are skipped. When no commit warrants a release it exits successfully with `released=false`; `--bump major|minor|patch` forces a release.
3. Rewrites the version across workspace member manifests, internal dependency pins, and `Cargo.lock`.
4. Creates the release commit directly on the base branch (fast-forward only — if the branch advanced past the analyzed head, the run skips cleanly), the `vX.Y.Z` tag (annotated by default so provenance checks like `git cat-file -t` pass; `--tag-style lightweight` opts out), an optional moving major alias tag (`--update-major-alias`), and the GitHub Release with grouped release notes.
5. Writes release notes to `--notes-file` and `released`/`version`/`tag`/`release-url`/`notes-file` outputs to `$GITHUB_OUTPUT` when set.

Release mode requires a token with `contents: write` on the target repository. The `workflow` scope is not required because release commits only touch Cargo manifests.

## Token Permissions

Remote PR mode requires a GitHub token with the equivalent of:

- `repo` or `public_repo`
- `workflow`

`pin`, `update --dry-run`, and `status` can run without a token. Authenticated requests are still recommended for GitHub-backed operations to raise API rate limits.

## Rate Limits

The GitHub client retries transient failures and rate-limited responses. The crates.io client also retries `429` and `5xx` responses with `Retry-After` handling.

GitHub handling includes:

- `429 Too Many Requests`
- `403 Forbidden` responses that carry rate-limit exhaustion headers
- server-side `5xx` responses
- connection and timeout errors from the HTTP client

When GitHub returns reset metadata, the client sleeps until the reset window instead of blindly retrying.

## GitHub Action Usage

One action ships every command. Reference it as `ThreatFlux/github_actions@<ref>`
(the root [`action.yml`](action.yml)) and select the behavior with `command`:

| `command` | What it does |
|---|---|
| `pin` | Rewrite floating action refs in workflow files to the commit SHA they resolve to today. |
| `update` | Move GitHub Actions and/or cargo dependencies to their latest upstream version, locally or on a pull request. |
| `status` | Report current versus latest versions without writing anything. |
| `release` | Bump the Cargo version from conventional commits and publish the release commit, tag, and GitHub Release — or stage them on a release pull request. |

The action is a Docker container action built from
[`runtime/Dockerfile`](runtime/Dockerfile), which pulls a digest-pinned
prebuilt image, so it starts in seconds instead of compiling from source.

### Inputs

| Input | Commands | Default | Description |
|---|---|---|---|
| `command` | all | `pin` | Command to run: `pin`, `update`, `status`, or `release`. |
| `token` | all | `${{ github.token }}` | GitHub token. Required for remote pull request creation and for `release`; recommended everywhere to raise API rate limits. |
| `owner` / `repo-name` | all | from `GITHUB_REPOSITORY` | Target repository coordinates. |
| `repo` | all | `.` | Path to the checked-out repository. |
| `base-branch` | `update`, `release` | repository default branch | Base of the dependency pull request, or the branch to release from. |
| `dry-run` | all | `false` | Analyze and report without writing files, commits, tags, releases, or pull requests. |
| `create-pr` | `update`, `release` | `false` | Open a dependency-update pull request, or stage the release on a release pull request instead of publishing directly. |
| `commit-message` | `update`, `release` | per command | `Update dependencies` for `update`; `chore: release v{version}` for `release`. |
| `workflows-path` | `pin`, `update`, `status` | `.github/workflows` | Workflow directory relative to the repository root. |
| `github-actions` | `update`, `status` | `false` | Include GitHub Actions workflow updates. |
| `cargo` | `update`, `status` | `false` | Include cargo package dependency updates. |
| `all` | `update`, `status` | `false` | Include both GitHub Actions and cargo updates. |
| `branch-name` | `update` | generated | Branch name for the dependency-update pull request. |
| `labels` | `update` | `dependencies` | Comma-separated labels for the dependency-update pull request. |
| `title` | `update` | `Update dependencies` | Title for the dependency-update pull request. |
| `bump` | `release` | `auto` | `auto`, `major`, `minor`, or `patch`. |
| `tag-prefix` | `release` | `v` | Prefix for release tags. |
| `tag-style` | `release` | `annotated` | `annotated` or `lightweight`. |
| `update-major-alias` | `release` | `false` | Also move the moving major alias tag (for example `v0`). |
| `notes-file` | `release` | `release_notes.md` | Where generated release notes are written, including on dry runs. |
| `release-branch` | `release` | `automation/release` | Automation-owned branch used with `create-pr`; must use the `automation/release` prefix. |

### Outputs

Every output is set by `release` and is empty for the other commands.

| Output | Description |
|---|---|
| `released` | `true` when a release was created, otherwise `false`. |
| `version` | Released version without the tag prefix (also set on dry runs and tag-exists skips). |
| `tag` | Created release tag. |
| `release-url` | URL of the created GitHub Release. |
| `notes-file` | Path to the generated notes file, empty when no notes were generated. |
| `release-pr-number` | Number of the created or updated release pull request. |
| `release-pr-url` | URL of the created or updated release pull request. |
| `release-branch` | Branch used for the release pull request. |

### `command: pin`

```yaml
jobs:
  pin:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Pin workflow action refs
        uses: ThreatFlux/github_actions@v0 # pin to a SHA in production
        with:
          command: pin
          token: ${{ secrets.GITHUB_TOKEN }}
```

`pin` rewrites the checked-out files in place; commit them yourself, or add
`dry-run: "true"` to report the rewrites without touching the working tree.

### `command: status`

```yaml
      - name: Report dependency drift
        uses: ThreatFlux/github_actions@v0 # pin to a SHA in production
        with:
          command: status
          all: "true"
          token: ${{ secrets.GITHUB_TOKEN }}
```

`status` never writes; `contents: read` is enough.

### `command: update`

```yaml
jobs:
  update:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Update workflow and cargo dependencies
        uses: ThreatFlux/github_actions@v0 # pin to a SHA in production
        with:
          command: update
          all: "true"
          create-pr: "true"
          token: ${{ secrets.DEPENDENCY_UPDATE_TOKEN }}
          owner: ${{ github.repository_owner }}
          repo-name: ${{ github.event.repository.name }}
```

`create-pr: "true"` publishes through the GitHub API rather than editing the
checkout, so the job itself needs no write permission — but the *token* does.
Updating files under `.github/workflows/` requires the `workflow` scope, which
the default `GITHUB_TOKEN` does not have; use a PAT or GitHub App token there.
Without `create-pr`, `update` edits the checked-out files and you commit them.
`create-pr` is one of the inputs affected by the
[version skew note](#version-skew-during-upgrades) below.

### `command: release`

Direct mode publishes the release commit, tag, and GitHub Release:

```yaml
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - id: release
        uses: ThreatFlux/github_actions@v0 # pin to a SHA in production
        with:
          command: release
          token: ${{ secrets.GITHUB_TOKEN }}
          update-major-alias: "true"
      - if: steps.release.outputs.released == 'true'
        run: echo "Released ${{ steps.release.outputs.tag }} -> ${{ steps.release.outputs.release-url }}"
```

Release-pull-request mode stages the version bump on the automation-owned
branch and opens or refreshes one pull request instead of publishing:

```yaml
jobs:
  release-pr:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - id: release
        uses: ThreatFlux/github_actions@v0 # pin to a SHA in production
        with:
          command: release
          token: ${{ secrets.GITHUB_TOKEN }}
          create-pr: "true"
          release-branch: automation/release
      - name: Report the release pull request
        run: |
          echo "PR #${{ steps.release.outputs.release-pr-number }} on ${{ steps.release.outputs.release-branch }}"
          echo "${{ steps.release.outputs.release-pr-url }}"
```

In pull-request mode no tag moves and no GitHub Release is published, so
`released` stays `false` and `release-pr-number`/`release-pr-url`/`release-branch`
carry the result; the tag and Release are cut by the follow-on release run
after the pull request merges. Merges whose commits are only `chore:`/`docs:`
produce no release at all — the action exits successfully with
`released=false`, which is also what keeps release commits from looping.

### Tokens and downstream pipelines

GitHub suppresses workflow triggers for events created with the default
`GITHUB_TOKEN`: the tag `release` creates will **not** start your tag-triggered
(`on: push: tags:`) workflows. Pick one of:

1. **Same-workflow chaining (no extra secrets):** gate follow-on jobs on
   `steps.release.outputs.released == 'true'`, or dispatch the tag pipelines
   explicitly — `workflow_dispatch` is exempt from the suppression rule. The
   dispatching job needs `permissions: actions: write`.
2. **GitHub App or PAT token:** pass it as `token` and the created tag triggers
   `on: push: tags:` workflows natively.
3. **No downstream pipelines:** the default `GITHUB_TOKEN` is all you need.

`release` needs a token with `contents: write` (plus `pull-requests: write` for
`create-pr`). The `workflow` scope is not required, because release commits
only touch Cargo manifests.

### Branch protection

Direct-mode `release` pushes the release commit to the base branch
(fast-forward only). On a protected branch, add a bypass for the identity the
token represents — for rulesets, add the GitHub Actions app or your own GitHub
App to the bypass list. `create-pr: "true"` is the alternative that respects
branch protection: it never writes to the protected branch directly.

The action pins its analysis to the branch head it first observes and publishes
with a fast-forward-only ref update, so a branch that advances mid-run makes the
run skip cleanly with `released=false` instead of releasing a stale commit. If
the computed tag already exists, the run skips as well. Run release jobs under a
`concurrency` group regardless.

### GitHub App authentication

To attribute release commits and pull requests to an App instead of
`github-actions[bot]`:

1. Create a GitHub App under your organization and generate a private key.
2. Grant the installation **Contents: Read and write**, **Pull requests: Read
   and write**, and **Actions: Read and write**, then install it on every
   repository that releases.
3. Add the App's numeric ID as the `RELEASE_APP_ID` repository or organization
   variable.
4. Add the private-key PEM as the `RELEASE_APP_PRIVATE_KEY` secret. Never commit
   the PEM or store it in a plain-text variable.
5. Pass `github-app-id` and the `github-app-private-key` secret to the reusable
   workflow below, which mints the installation token with
   `actions/create-github-app-token` and hands it to the action.

App authentication attributes API commits and pull requests to the App;
cryptographic commit signing still requires a separate signing-key policy.
Configure both values together — a half-configured App fails the workflow
instead of silently falling back.

## Reusable Auto Release Workflow

For Cargo repositories that want the whole release gate — required-workflow
checks, optional GitHub App authentication, and downstream workflow dispatches
— call the reusable workflow instead of wiring the action yourself:

```yaml
name: Auto Release
on:
  push:
    branches: [main]
concurrency:
  group: auto-release-${{ github.ref }}
permissions:
  contents: read
  actions: read
  pull-requests: read
jobs:
  release:
    permissions:
      contents: write
      actions: write
      pull-requests: write
    uses: ThreatFlux/github_actions/.github/workflows/reusable-auto-release.yml@v0 # pin to a SHA in production
    with:
      bump: auto
```

The reusable workflow needs no separate action pin: it checks out and runs the
action at its own commit (`job.workflow_sha`), so the action version always
matches whatever workflow ref you pinned. Its inputs, outputs, and the
`required-workflows` / `dispatch-workflows` gate are documented in
[release/README.md](release/README.md#reusable-workflow-inputs).

## Migrating to the Unified Action

This repository used to ship two actions. It now ships one; `release/` is
deprecated.

| Before | After |
|---|---|
| `uses: ThreatFlux/github_actions/release@v0` | `uses: ThreatFlux/github_actions@v0` plus `command: release` |
| `uses: ThreatFlux/github_actions@v0` (maintainer) | unchanged, but state `command:` explicitly — it defaults to `pin` |

Every `release/` input keeps its name and default on the unified action, and
all eight release outputs are unchanged, so migrating is the two-line edit
above. Root-action users who were already passing inputs such as
`workflows-path`, `all`, `labels`, or `title` should read the version-skew note
below: those inputs now travel to the binary as environment variables and need
runtime image 0.6.1 or newer to take effect.

### Version skew during upgrades

Only the flags every published binary accepts are passed as container
arguments (`command`, `repo`, `token`, `owner`, `repo-name`, `base-branch`,
`dry-run`). Every other input reaches the binary through the `INPUT_<NAME>`
environment variables GitHub sets for container actions. That keeps the action
working while [`runtime/Dockerfile`](runtime/Dockerfile) still pins a pre-0.6.0
image — but that older binary ignores `INPUT_*` variables entirely.

Until the first post-merge release (0.6.0) publishes and Dependabot bumps the
`/runtime` pin (yielding 0.6.1), these inputs silently fall back to their
built-in defaults:

- `pin`, `update`, `status`: `workflows-path`, `github-actions`, `cargo`,
  `all`, `branch-name`, `labels`, `title`, `commit-message`, `create-pr`
- `release`: `bump`, `tag-prefix`, `tag-style`, `update-major-alias`,
  `notes-file`, `release-branch`, `commit-message`, `create-pr`

Most notably, **`create-pr: "true"` on `release` performs a direct release
instead of opening a release pull request** during that window, and
`create-pr: "true"` on `update` rewrites the checkout instead of opening a
dependency pull request. Pin the action
to a ref whose `runtime/Dockerfile` holds 0.6.1 or newer before relying on any
of these inputs. The skew self-heals once that pin lands.

### Deprecation timeline

`ThreatFlux/github_actions/release@<ref>` still works and still takes the same
inputs, but it is deprecated as of the unified action and will be **removed in
the next major version**. New workflows should use `command: release` on the
root action; existing ones have the whole `v0` line to migrate.

Roadmap: the release engine is manifest-driven (`src/versioning.rs`), with Cargo supported today; npm (`package.json`) and Python (`pyproject.toml`) manifest adapters are planned next so the same action covers the whole ThreatFlux org.

## Development

```bash
make dev-setup
cargo fmt --all
cargo clippy --all-features --all-targets -- -D warnings
cargo test --all-features
```

## Architecture

The Go-based `githubWorkFlowChecker` concept was narrowed for the initial Rust implementation:

- keep the repo general-purpose for future GitHub Actions maintenance features
- ship secure pinning first, then add version-aware updates and authenticated PR publishing
- separate scanning, GitHub resolution, and rewrite orchestration into small modules

See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the current design.