From 9b357736b2514ed70e719512fa0546915ba4e01e Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Wed, 11 Nov 2026 23:15:32 +0100 Subject: [PATCH] feat(ai-step): refresh github credential without a session Dry runs can reach an expired GitHub MCP bearer between flake fixer runs. Provide a renewal-only entry point so a scheduled job can keep the existing scoped app credential current without starting a model session. --- .github/actions/ai-step/README.md | 9 +++++ .github/actions/ai-step/refresh/action.yml | 33 +++++++++++++++++++ .github/actions/ai-step/src/refresh_github.py | 30 +++++++++++++++++ .../ai-step/test/test_refresh_github.py | 30 +++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 .github/actions/ai-step/refresh/action.yml create mode 100644 .github/actions/ai-step/src/refresh_github.py create mode 100644 .github/actions/ai-step/test/test_refresh_github.py diff --git a/.github/actions/ai-step/README.md b/.github/actions/ai-step/README.md index 08f1b4a0..055c29e1 100644 --- a/.github/actions/ai-step/README.md +++ b/.github/actions/ai-step/README.md @@ -294,3 +294,12 @@ make test-ai-step ``` Runs the bats suite in `test/` against `src/resolve-config.sh`. + +### Refresh a GitHub MCP credential without a session + +Use `loft-sh/github-actions/.github/actions/ai-step/refresh@ai-step/v1` with +`vault-id`, `github-app-id`, `github-app-installation-id`, +`github-app-private-key`, and `github-app-repository`. The caller must supply +Anthropic workload identity federation environment variables and an identity +token. This renews the vault bearer once and starts no agent session. Failure +fails the step without printing API error details. diff --git a/.github/actions/ai-step/refresh/action.yml b/.github/actions/ai-step/refresh/action.yml new file mode 100644 index 00000000..27f63f6f --- /dev/null +++ b/.github/actions/ai-step/refresh/action.yml @@ -0,0 +1,33 @@ +name: Refresh GitHub MCP credential +description: Renew a repository-scoped GitHub App token in a managed-agent vault without starting a session. +inputs: + vault-id: + description: Managed-agent vault ID. + required: true + github-app-id: + description: GitHub App ID. + required: true + github-app-installation-id: + description: GitHub App installation ID. + required: true + github-app-private-key: + description: GitHub App private key. + required: true + github-app-repository: + description: Single repository to scope the token to. + required: true +runs: + using: composite + steps: + - name: Install Anthropic SDK + shell: bash + run: python3 -m pip install --quiet --disable-pip-version-check anthropic + - name: Refresh credential + shell: bash + env: + INPUT_VAULT_ID: ${{ inputs.vault-id }} + INPUT_GITHUB_APP_ID: ${{ inputs.github-app-id }} + INPUT_GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }} + INPUT_GITHUB_APP_PRIVATE_KEY: ${{ inputs.github-app-private-key }} + INPUT_GITHUB_APP_REPOSITORY: ${{ inputs.github-app-repository }} + run: python3 "$GITHUB_ACTION_PATH/../src/refresh_github.py" diff --git a/.github/actions/ai-step/src/refresh_github.py b/.github/actions/ai-step/src/refresh_github.py new file mode 100644 index 00000000..3c726dc4 --- /dev/null +++ b/.github/actions/ai-step/src/refresh_github.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Refresh the GitHub MCP bearer without creating an agent session.""" +import os +import sys + + +def main(): + try: + from anthropic import Anthropic + from github_app import GitHubCredential + + client = Anthropic(default_headers={"anthropic-beta": "managed-agents-2026-04-01"}) + credential = GitHubCredential( + client, os.environ.get("INPUT_VAULT_ID", ""), + os.environ.get("INPUT_GITHUB_APP_ID", ""), + os.environ.get("INPUT_GITHUB_APP_INSTALLATION_ID", ""), + os.environ.get("INPUT_GITHUB_APP_PRIVATE_KEY", ""), + os.environ.get("INPUT_GITHUB_APP_REPOSITORY", ""), + ) + credential.renew(force=True) + except Exception: + # SDK and API errors can contain credentials. Never print their details. + print("::error::GitHub MCP credential refresh failed", file=sys.stderr) + return 1 + print("GitHub MCP credential refreshed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/ai-step/test/test_refresh_github.py b/.github/actions/ai-step/test/test_refresh_github.py new file mode 100644 index 00000000..f728b60f --- /dev/null +++ b/.github/actions/ai-step/test/test_refresh_github.py @@ -0,0 +1,30 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +spec = importlib.util.spec_from_file_location("refresh_github", Path(__file__).parents[1] / "src/refresh_github.py") +refresh = importlib.util.module_from_spec(spec) +spec.loader.exec_module(refresh) + + +def test_renews_once_without_creating_session(monkeypatch): + client = Mock() + credential = Mock() + factory = Mock(return_value=credential) + monkeypatch.setitem(sys.modules, "anthropic", SimpleNamespace(Anthropic=Mock(return_value=client))) + monkeypatch.setitem(sys.modules, "github_app", SimpleNamespace(GitHubCredential=factory)) + for name, value in {"INPUT_VAULT_ID": "vault", "INPUT_GITHUB_APP_ID": "123", "INPUT_GITHUB_APP_INSTALLATION_ID": "456", "INPUT_GITHUB_APP_PRIVATE_KEY": "key", "INPUT_GITHUB_APP_REPOSITORY": "owner/repo"}.items(): + monkeypatch.setenv(name, value) + assert refresh.main() == 0 + factory.assert_called_once_with(client, "vault", "123", "456", "key", "owner/repo") + credential.renew.assert_called_once_with(force=True) + client.beta.sessions.create.assert_not_called() + + +def test_failure_does_not_print_exception(monkeypatch, capsys): + monkeypatch.setitem(sys.modules, "anthropic", SimpleNamespace(Anthropic=Mock(side_effect=ValueError("secret")))) + monkeypatch.setitem(sys.modules, "github_app", SimpleNamespace(GitHubCredential=Mock())) + assert refresh.main() == 1 + assert "secret" not in capsys.readouterr().err