From f9275f23368865b12db69969e7dcf1edc460b9b5 Mon Sep 17 00:00:00 2001 From: Alberto Donato Date: Tue, 27 Oct 2026 12:42:49 +0000 Subject: [PATCH 1/3] feat: let run-script assume an AWS role and install an SSH key ### what Add three 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. 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. ### 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. ### 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`, so it exercises the permissions change; the AWS and SSH steps run only once a caller passes them. ### docs The README's `run-script` section covers both new parameters, the permissions a caller must grant, and an example caller. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/run-script.yml | 38 +++++++++++++++++++++++++++++--- README.md | 26 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-script.yml b/.github/workflows/run-script.yml index a07a6ed..41b9ebb 100644 --- a/.github/workflows/run-script.yml +++ b/.github/workflows/run-script.yml @@ -22,17 +22,30 @@ on: type: string description: "Value for MISE_ENV, selecting additional mise..toml config files" default: "" + aws-role: + type: string + description: "ARN of an AWS role to assume through GitHub OIDC before the script runs. The caller must grant id-token: write" + default: "" + aws-region: + type: string + description: "AWS region for the credentials aws-role yields" + default: "us-east-1" secrets: STEP_SECURITY_API_KEY: required: false -permissions: {} + SSH_PRIVATE_KEY: + description: "SSH private key to install for the script, e.g. a deploy key for cloning a private repository" + required: false +# No permissions here: the job takes exactly what the caller grants. That is +# contents: read for most callers, plus id-token: write for those passing +# aws-role. A called workflow cannot be granted more than its caller has, and +# asking for id-token: write here would fail every caller that doesn't grant it. jobs: run: runs-on: ubuntu-latest - permissions: - contents: read env: HARDEN_RUNNER: ${{ secrets.STEP_SECURITY_API_KEY != '' }} # zizmor: ignore[secrets-outside-env] + SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY != '' }} # zizmor: ignore[secrets-outside-env] MISE_ENV: ${{ inputs.mise-env }} MISE_LOCKED: "1" steps: @@ -49,6 +62,25 @@ jobs: persist-credentials: false fetch-depth: ${{ inputs.full-git-history && '0' || '1' }} + - name: Configure AWS credentials + if: inputs.aws-role != '' + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0 + with: + role-to-assume: ${{ inputs.aws-role }} + aws-region: ${{ inputs.aws-region }} + + # The runner's known_hosts already covers github.com. The key goes in a + # file of its own, named in ~/.ssh/config, so it works whatever its type. + - name: Install SSH key + if: env.SSH_KEY == 'true' + run: | + umask 077 + mkdir -p ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/run-script-key + printf 'Host *\n IdentityFile ~/.ssh/run-script-key\n' >> ~/.ssh/config + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] + - name: Install mise uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 with: diff --git a/README.md b/README.md index 75fe760..7e56bee 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,32 @@ are loaded on top of the default config. just lint ``` +The job runs with exactly the permissions the caller grants, so grant at least +`contents: read`. + +Set `aws-role` to assume that AWS role through GitHub OIDC before the script runs, +with `aws-region` if it isn't `us-east-1`. The caller must then grant `id-token: write` +as well. + +Pass `SSH_PRIVATE_KEY` to give the script an SSH key, such as a deploy key that +pre-commit needs to clone a private hook repository over SSH. + +``` + Lint: + permissions: + contents: read + id-token: write + uses: stacklet/github-tooling/.github/workflows/run-script.yml@ + secrets: + STEP_SECURITY_API_KEY: ${{ secrets.STEP_SECURITY_API_KEY }} + SSH_PRIVATE_KEY: ${{ secrets.PRECOMMIT_SSH_KEY }} + with: + aws-role: arn:aws:iam::123456789012:role/ci + pre-commit-cache: true + run: | + just lint +``` + ### `cut-release` Works out the next version from the tag history and publishes a GitHub release for it, From 46452daef57a0f30ce8839532c02f461e2185e8b Mon Sep 17 00:00:00 2001 From: Alberto Donato Date: Tue, 27 Oct 2026 15:56:13 +0000 Subject: [PATCH 2/3] feat: add an optional uv cache to run-script and fix its pre-commit cache ### what - Add a `uv-cache` input (default false) that restores uv's cache on every run, falling back to the newest one when `uv.lock` moved, and prunes it with `uv cache prune --ci` and saves it off pull requests. - Key the pre-commit cache on the mise config and lockfiles and the selected `mise-env` as well as `.pre-commit-config.yaml`. - Export `PREK_HOME` from `$HOME` in the script instead of setting it in the step's `env:`. ### why 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`). This repository's CI uses `pre-commit-cache`, so it exercises the new key and `PREK_HOME`. ### docs The README's `run-script` section describes both caches. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/run-script.yml | 49 +++++++++++++++++++++++++++----- README.md | 5 ++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/.github/workflows/run-script.yml b/.github/workflows/run-script.yml index 41b9ebb..81c8155 100644 --- a/.github/workflows/run-script.yml +++ b/.github/workflows/run-script.yml @@ -10,6 +10,10 @@ on: description: "Restore the pre-commit cache, saving it on non-PR events" type: boolean default: false + uv-cache: + description: "Restore uv's cache, saving it on non-PR events" + type: boolean + default: false full-git-history: type: boolean description: "Whether to fetch full git history rather than just current HEAD" @@ -87,32 +91,63 @@ jobs: minimum_release_age: 7d cache: true - # Always read the cache, it's safe to do so even on untrusted PRs. + # Always read the caches, it's safe to do so even on untrusted PRs. + # + # Hook environments are built with the toolchains mise installs (Python, + # Node, ...), so the key covers the mise config and lockfiles and the + # selected environment as well as the hook pins: a toolchain bump must + # not restore environments built against the old one. - name: Restore pre-commit cache if: inputs.pre-commit-cache id: pre-commit-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pre-commit - key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + key: pre-commit-${{ runner.os }}-${{ runner.arch }}-${{ inputs.mise-env }}-${{ hashFiles('.pre-commit-config.yaml', 'mise.toml', 'mise.lock', 'mise.*.toml', 'mise.*.lock') }} + + # uv verifies what it installs against uv.lock's hashes, so a stale entry + # only costs a download; restore the newest cache when the lock moved. + - name: Restore uv cache + if: inputs.uv-cache + id: uv-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/uv + key: uv-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock') }} + restore-keys: | + uv-${{ runner.os }}-${{ runner.arch }}- - name: Run # Some tools reject an empty GH_TOKEN (e.g. zizmor), so unset it rather # than leaving it empty. run: | # zizmor: ignore[template-injection] caller-provided script [ -n "$GH_TOKEN" ] || unset GH_TOKEN + # Point prek at the pre-commit cache path so the cache step covers + # both. Exported here because `env:` values don't expand `~`. + export PREK_HOME="$HOME/.cache/pre-commit" ${{ inputs.run }} env: GH_TOKEN: ${{ inputs.github-token && github.token || '' }} - # Point prek at the pre-commit cache path so the cache step covers both. - PREK_HOME: ~/.cache/pre-commit - # Cache ssave is gated to non-PR events: an untrusted PR run must not be - # able to write a hook-environment cache that a later trusted build - # restores and executes (cache poisoning). + # Cache saves are gated to non-PR events: an untrusted PR run must not be + # able to write a cache that a later trusted build restores and executes + # (cache poisoning). - name: Save pre-commit cache if: inputs.pre-commit-cache && github.event_name != 'pull_request' && steps.pre-commit-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pre-commit key: ${{ steps.pre-commit-cache.outputs.cache-primary-key }} + + # Keep only what's slow to rebuild: --ci drops pre-built wheels, which + # download again quickly, and keeps wheels built from source. + - name: Prune uv cache + if: inputs.uv-cache && github.event_name != 'pull_request' && steps.uv-cache.outputs.cache-hit != 'true' + run: uv cache prune --ci + + - name: Save uv cache + if: inputs.uv-cache && github.event_name != 'pull_request' && steps.uv-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/uv + key: ${{ steps.uv-cache.outputs.cache-primary-key }} diff --git a/README.md b/README.md index 7e56bee..a3561bc 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ are loaded on top of the default config. just lint ``` +Set `pre-commit-cache` to keep pre-commit's (or prek's) hook environments between +runs, and `uv-cache` to keep uv's cache. Both are restored on every run but saved only +off pull requests, so an untrusted pull request can't write a cache a later trusted run +restores. + The job runs with exactly the permissions the caller grants, so grant at least `contents: read`. From 25bf0ff2d23c4bbeabbb4cee7031b773dd8b3d57 Mon Sep 17 00:00:00 2001 From: Alberto Donato Date: Wed, 28 Oct 2026 07:45:14 +0000 Subject: [PATCH 3/3] fix: keep run-script caches from fork PRs of public repos A trusted run can fill the pre-commit and uv caches with private packages or clones of private hook repositories, which a fork's script could read. Skip restoring them for pull requests from forks of a public repository. Also stop calling the github-token token read-only: the job takes the caller's permissions, so the caller's grant is the only cap on it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/run-script.yml | 14 ++++++++++---- README.md | 14 ++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/run-script.yml b/.github/workflows/run-script.yml index 81c8155..34a9d35 100644 --- a/.github/workflows/run-script.yml +++ b/.github/workflows/run-script.yml @@ -20,7 +20,7 @@ on: default: false github-token: type: boolean - description: "Expose the job's read-only GitHub token to the script as GH_TOKEN" + 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" default: false mise-env: type: string @@ -44,6 +44,8 @@ on: # contents: read for most callers, plus id-token: write for those passing # aws-role. A called workflow cannot be granted more than its caller has, and # asking for id-token: write here would fail every caller that doesn't grant it. +# The caller's grant is therefore the only cap on the token github-token +# exposes, so callers must grant no more than contents: read (and id-token). jobs: run: runs-on: ubuntu-latest @@ -91,14 +93,18 @@ jobs: minimum_release_age: 7d cache: true - # Always read the caches, it's safe to do so even on untrusted PRs. + # Caches are restored on every run except pull requests from forks of a + # public repository: a trusted run may have filled them with private + # artifacts (packages from a private registry, clones of private hook + # repositories), which an outsider's script could read and exfiltrate. + # Forks of a private repository belong to people who can already read it. # # Hook environments are built with the toolchains mise installs (Python, # Node, ...), so the key covers the mise config and lockfiles and the # selected environment as well as the hook pins: a toolchain bump must # not restore environments built against the old one. - name: Restore pre-commit cache - if: inputs.pre-commit-cache + if: inputs.pre-commit-cache && !(github.event.pull_request.head.repo.fork && !github.event.repository.private) id: pre-commit-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -108,7 +114,7 @@ jobs: # uv verifies what it installs against uv.lock's hashes, so a stale entry # only costs a download; restore the newest cache when the lock moved. - name: Restore uv cache - if: inputs.uv-cache + if: inputs.uv-cache && !(github.event.pull_request.head.repo.fork && !github.event.repository.private) id: uv-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/README.md b/README.md index a3561bc..ca57236 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,14 @@ are loaded on top of the default config. ``` Set `pre-commit-cache` to keep pre-commit's (or prek's) hook environments between -runs, and `uv-cache` to keep uv's cache. Both are restored on every run but saved only -off pull requests, so an untrusted pull request can't write a cache a later trusted run -restores. - -The job runs with exactly the permissions the caller grants, so grant at least -`contents: read`. +runs, and `uv-cache` to keep uv's cache. Both are saved only off pull requests, so an +untrusted pull request can't write a cache a later trusted run restores, and restored on +every run except pull requests from forks of a public repository, since a trusted run +may have filled them with private packages or hook repositories. + +The job runs with exactly the permissions the caller grants, so grant +`contents: read` and nothing more (plus `id-token: write` for `aws-role`): with +`github-token` set, the script gets a token carrying all of them. Set `aws-role` to assume that AWS role through GitHub OIDC before the script runs, with `aws-region` if it isn't `us-east-1`. The caller must then grant `id-token: write`