Skip to content

Commit a1be6a1

Browse files
feat: run-script AWS role, SSH key and uv cache, and fix its pre-commit cache (#7)
### what Add four optional parameters to the `run-script` workflow: - `aws-role` (input): an AWS role to assume through GitHub OIDC with `aws-actions/configure-aws-credentials` before the script runs. `aws-region` (input, default `us-east-1`) sets its region. - `SSH_PRIVATE_KEY` (secret): an SSH key for the script, written to `~/.ssh/run-script-key` and named in `~/.ssh/config`, for example a deploy key pre-commit needs to clone a private hook repository. - `uv-cache` (input, default false): restores uv's cache, falling back to the newest one when `uv.lock` moved, and prunes it with `uv cache prune --ci` and saves it off pull requests. Neither cache is restored for pull requests from forks of a public repository. A trusted run may have filled them with private packages or clones of private hook repositories, which the fork's script could read. Forks of a private repository belong to people who can already read it, so those still restore. It also fixes the pre-commit cache. The key now covers the mise config and lockfiles and the selected `mise-env` as well as `.pre-commit-config.yaml`, and `PREK_HOME` is exported from `$HOME` in the script instead of set in the step's `env:`. The workflow no longer sets permissions of its own, so its job runs with exactly what the caller grants. OIDC needs `id-token: write`, and a called workflow can't be granted more than its caller has, so requesting it here would likely fail every caller that doesn't grant it. Existing callers already grant `contents: read`, so they run as before. The caller's grant is now the only cap on the token `github-token` exposes, so the input no longer calls it read-only, and the README says to grant nothing beyond `contents: read` (plus `id-token: write` for `aws-role`). ### why Jobs that install a project's dependencies from a private registry, or whose pre-commit hooks come from a private repository, can't use `run-script` today. Platform's Lint job needs both: AWS credentials for CodeArtifact and the pre-commit deploy key. Installing its dependencies on every run is also slow without uv's cache. Hook environments are built with the toolchains mise installs, so a Python bump restored environments built against the old interpreter. `PREK_HOME` was the literal string `~/.cache/pre-commit`, since `env:` values don't expand `~`, so prek wrote its environments to a `~` directory in the checkout and the cache step saved an empty one. ### testing actionlint, zizmor, ruff and codespell pass (`prek run --all-files`). zizmor accepts the job without a permissions block. This repository's own CI runs on `run-script` with `pre-commit-cache`, so it exercises the permissions change, the new pre-commit key and `PREK_HOME`. The AWS, SSH and uv cache steps run only once a caller passes them, and the fork check only on a fork's pull request. ### docs The README's `run-script` section covers the new parameters, both caches and when they're skipped, the permissions a caller must grant, and an example caller. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 48cc95e commit a1be6a1

2 files changed

Lines changed: 118 additions & 12 deletions

File tree

‎.github/workflows/run-script.yml‎

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,48 @@ on:
1010
description: "Restore the pre-commit cache, saving it on non-PR events"
1111
type: boolean
1212
default: false
13+
uv-cache:
14+
description: "Restore uv's cache, saving it on non-PR events"
15+
type: boolean
16+
default: false
1317
full-git-history:
1418
type: boolean
1519
description: "Whether to fetch full git history rather than just current HEAD"
1620
default: false
1721
github-token:
1822
type: boolean
19-
description: "Expose the job's read-only GitHub token to the script as GH_TOKEN"
23+
description: "Expose the job's GitHub token to the script as GH_TOKEN. It carries whatever permissions the caller grants, so grant only what the script needs"
2024
default: false
2125
mise-env:
2226
type: string
2327
description: "Value for MISE_ENV, selecting additional mise.<env>.toml config files"
2428
default: ""
29+
aws-role:
30+
type: string
31+
description: "ARN of an AWS role to assume through GitHub OIDC before the script runs. The caller must grant id-token: write"
32+
default: ""
33+
aws-region:
34+
type: string
35+
description: "AWS region for the credentials aws-role yields"
36+
default: "us-east-1"
2537
secrets:
2638
STEP_SECURITY_API_KEY:
2739
required: false
28-
permissions: {}
40+
SSH_PRIVATE_KEY:
41+
description: "SSH private key to install for the script, e.g. a deploy key for cloning a private repository"
42+
required: false
43+
# No permissions here: the job takes exactly what the caller grants. That is
44+
# contents: read for most callers, plus id-token: write for those passing
45+
# aws-role. A called workflow cannot be granted more than its caller has, and
46+
# asking for id-token: write here would fail every caller that doesn't grant it.
47+
# The caller's grant is therefore the only cap on the token github-token
48+
# exposes, so callers must grant no more than contents: read (and id-token).
2949
jobs:
3050
run:
3151
runs-on: ubuntu-latest
32-
permissions:
33-
contents: read
3452
env:
3553
HARDEN_RUNNER: ${{ secrets.STEP_SECURITY_API_KEY != '' }} # zizmor: ignore[secrets-outside-env]
54+
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY != '' }} # zizmor: ignore[secrets-outside-env]
3655
MISE_ENV: ${{ inputs.mise-env }}
3756
MISE_LOCKED: "1"
3857
steps:
@@ -49,38 +68,92 @@ jobs:
4968
persist-credentials: false
5069
fetch-depth: ${{ inputs.full-git-history && '0' || '1' }}
5170

71+
- name: Configure AWS credentials
72+
if: inputs.aws-role != ''
73+
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
74+
with:
75+
role-to-assume: ${{ inputs.aws-role }}
76+
aws-region: ${{ inputs.aws-region }}
77+
78+
# The runner's known_hosts already covers github.com. The key goes in a
79+
# file of its own, named in ~/.ssh/config, so it works whatever its type.
80+
- name: Install SSH key
81+
if: env.SSH_KEY == 'true'
82+
run: |
83+
umask 077
84+
mkdir -p ~/.ssh
85+
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/run-script-key
86+
printf 'Host *\n IdentityFile ~/.ssh/run-script-key\n' >> ~/.ssh/config
87+
env:
88+
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env]
89+
5290
- name: Install mise
5391
uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1
5492
with:
5593
minimum_release_age: 7d
5694
cache: true
5795

58-
# Always read the cache, it's safe to do so even on untrusted PRs.
96+
# Caches are restored on every run except pull requests from forks of a
97+
# public repository: a trusted run may have filled them with private
98+
# artifacts (packages from a private registry, clones of private hook
99+
# repositories), which an outsider's script could read and exfiltrate.
100+
# Forks of a private repository belong to people who can already read it.
101+
#
102+
# Hook environments are built with the toolchains mise installs (Python,
103+
# Node, ...), so the key covers the mise config and lockfiles and the
104+
# selected environment as well as the hook pins: a toolchain bump must
105+
# not restore environments built against the old one.
59106
- name: Restore pre-commit cache
60-
if: inputs.pre-commit-cache
107+
if: inputs.pre-commit-cache && !(github.event.pull_request.head.repo.fork && !github.event.repository.private)
61108
id: pre-commit-cache
62109
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
63110
with:
64111
path: ~/.cache/pre-commit
65-
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
112+
key: pre-commit-${{ runner.os }}-${{ runner.arch }}-${{ inputs.mise-env }}-${{ hashFiles('.pre-commit-config.yaml', 'mise.toml', 'mise.lock', 'mise.*.toml', 'mise.*.lock') }}
113+
114+
# uv verifies what it installs against uv.lock's hashes, so a stale entry
115+
# only costs a download; restore the newest cache when the lock moved.
116+
- name: Restore uv cache
117+
if: inputs.uv-cache && !(github.event.pull_request.head.repo.fork && !github.event.repository.private)
118+
id: uv-cache
119+
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
120+
with:
121+
path: ~/.cache/uv
122+
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock') }}
123+
restore-keys: |
124+
uv-${{ runner.os }}-${{ runner.arch }}-
66125
67126
- name: Run
68127
# Some tools reject an empty GH_TOKEN (e.g. zizmor), so unset it rather
69128
# than leaving it empty.
70129
run: | # zizmor: ignore[template-injection] caller-provided script
71130
[ -n "$GH_TOKEN" ] || unset GH_TOKEN
131+
# Point prek at the pre-commit cache path so the cache step covers
132+
# both. Exported here because `env:` values don't expand `~`.
133+
export PREK_HOME="$HOME/.cache/pre-commit"
72134
${{ inputs.run }}
73135
env:
74136
GH_TOKEN: ${{ inputs.github-token && github.token || '' }}
75-
# Point prek at the pre-commit cache path so the cache step covers both.
76-
PREK_HOME: ~/.cache/pre-commit
77137

78-
# Cache ssave is gated to non-PR events: an untrusted PR run must not be
79-
# able to write a hook-environment cache that a later trusted build
80-
# restores and executes (cache poisoning).
138+
# Cache saves are gated to non-PR events: an untrusted PR run must not be
139+
# able to write a cache that a later trusted build restores and executes
140+
# (cache poisoning).
81141
- name: Save pre-commit cache
82142
if: inputs.pre-commit-cache && github.event_name != 'pull_request' && steps.pre-commit-cache.outputs.cache-hit != 'true'
83143
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
84144
with:
85145
path: ~/.cache/pre-commit
86146
key: ${{ steps.pre-commit-cache.outputs.cache-primary-key }}
147+
148+
# Keep only what's slow to rebuild: --ci drops pre-built wheels, which
149+
# download again quickly, and keeps wheels built from source.
150+
- name: Prune uv cache
151+
if: inputs.uv-cache && github.event_name != 'pull_request' && steps.uv-cache.outputs.cache-hit != 'true'
152+
run: uv cache prune --ci
153+
154+
- name: Save uv cache
155+
if: inputs.uv-cache && github.event_name != 'pull_request' && steps.uv-cache.outputs.cache-hit != 'true'
156+
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
157+
with:
158+
path: ~/.cache/uv
159+
key: ${{ steps.uv-cache.outputs.cache-primary-key }}

‎README.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,39 @@ are loaded on top of the default config.
5757
just lint
5858
```
5959
60+
Set `pre-commit-cache` to keep pre-commit's (or prek's) hook environments between
61+
runs, and `uv-cache` to keep uv's cache. Both are saved only off pull requests, so an
62+
untrusted pull request can't write a cache a later trusted run restores, and restored on
63+
every run except pull requests from forks of a public repository, since a trusted run
64+
may have filled them with private packages or hook repositories.
65+
66+
The job runs with exactly the permissions the caller grants, so grant
67+
`contents: read` and nothing more (plus `id-token: write` for `aws-role`): with
68+
`github-token` set, the script gets a token carrying all of them.
69+
70+
Set `aws-role` to assume that AWS role through GitHub OIDC before the script runs,
71+
with `aws-region` if it isn't `us-east-1`. The caller must then grant `id-token: write`
72+
as well.
73+
74+
Pass `SSH_PRIVATE_KEY` to give the script an SSH key, such as a deploy key that
75+
pre-commit needs to clone a private hook repository over SSH.
76+
77+
```
78+
Lint:
79+
permissions:
80+
contents: read
81+
id-token: write
82+
uses: stacklet/github-tooling/.github/workflows/run-script.yml@<commit-sha>
83+
secrets:
84+
STEP_SECURITY_API_KEY: ${{ secrets.STEP_SECURITY_API_KEY }}
85+
SSH_PRIVATE_KEY: ${{ secrets.PRECOMMIT_SSH_KEY }}
86+
with:
87+
aws-role: arn:aws:iam::123456789012:role/ci
88+
pre-commit-cache: true
89+
run: |
90+
just lint
91+
```
92+
6093
### `cut-release`
6194
6295
Works out the next version from the tag history and publishes a GitHub release for it,

0 commit comments

Comments
 (0)