From 29a4787e07b56bdf579b0dcd2db8f4733af86acb Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 27 Oct 2026 12:53:29 +0700 Subject: [PATCH 1/5] ci: add an Anthropic workload identity federation smoke test Signed-off-by: ajianaz --- .github/workflows/anthropic-wif-smoke.yml | 89 +++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/anthropic-wif-smoke.yml diff --git a/.github/workflows/anthropic-wif-smoke.yml b/.github/workflows/anthropic-wif-smoke.yml new file mode 100644 index 0000000..c8d68a0 --- /dev/null +++ b/.github/workflows/anthropic-wif-smoke.yml @@ -0,0 +1,89 @@ +name: Anthropic WIF smoke test + +# Checks that this repository can exchange its GitHub OIDC token for a +# short-lived Anthropic access token (workload identity federation) and that +# the token works the way cora sends it: `Authorization: Bearer ` to the +# OpenAI-compatible `/chat/completions` endpoint. Prints HTTP statuses only, +# never a token. Runs when this file changes. + +on: + pull_request: + paths: + - ".github/workflows/anthropic-wif-smoke.yml" + +permissions: + id-token: write + contents: read + +jobs: + smoke: + name: Anthropic WIF smoke + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + # Identifiers, not credentials, but this repository is public: they live + # in repository variables instead of the workflow file. + FEDERATION_RULE_ID: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + ORGANIZATION_ID: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + SERVICE_ACCOUNT_ID: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} + WORKSPACE_ID: ${{ vars.ANTHROPIC_WORKSPACE_ID }} + MODEL: ${{ vars.ANTHROPIC_MODEL || 'claude-opus-5-5' }} + steps: + - name: Require the federation variables + run: | + missing=0 + for name in FEDERATION_RULE_ID ORGANIZATION_ID SERVICE_ACCOUNT_ID WORKSPACE_ID; do + if [ -z "${!name}" ]; then + echo "::error::Repository variable ANTHROPIC_${name} is empty or missing." + missing=1 + fi + done + exit "$missing" + + - name: Fetch the GitHub OIDC token + uses: actions/github-script@v8 + with: + script: | + const token = await core.getIDToken('https://api.anthropic.com'); + core.setSecret(token); + core.exportVariable('JWT', token); + + - name: Exchange it for an Anthropic access token + run: | + body=$(jq -n \ + --arg jwt "$JWT" \ + --arg rule "$FEDERATION_RULE_ID" \ + --arg org "$ORGANIZATION_ID" \ + --arg sa "$SERVICE_ACCOUNT_ID" \ + --arg ws "$WORKSPACE_ID" \ + '{grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: $jwt, federation_rule_id: $rule, organization_id: $org, + service_account_id: $sa, workspace_id: $ws}') + code=$(curl -sS -o exchange.json -w '%{http_code}' \ + https://api.anthropic.com/v1/oauth/token \ + -H "content-type: application/json" --data "$body") + echo "token exchange: HTTP $code" + if [ "$code" != "200" ]; then + jq 'with_entries(if (.key | test("token$")) then .value = "" else . end)' exchange.json || true + exit 1 + fi + token=$(jq -r '.access_token' exchange.json) + echo "::add-mask::$token" + echo "ACCESS_TOKEN=$token" >> "$GITHUB_ENV" + echo "token_type=$(jq -r '.token_type' exchange.json) expires_in=$(jq -r '.expires_in' exchange.json)" + rm -f exchange.json + + - name: Call the OpenAI-compatible endpoint the way cora does + run: | + code=$(curl -sS -o compat.json -w '%{http_code}' \ + https://api.anthropic.com/v1/chat/completions \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + --data "$(jq -n --arg m "$MODEL" '{model: $m, max_tokens: 16, messages: [{role: "user", content: "Reply with the single word ok."}]}')") + echo "chat/completions with Bearer: HTTP $code" + if [ "$code" = "200" ]; then + echo "reply length: $(jq -r '.choices[0].message.content | length' compat.json)" + else + jq -r '.error | "\(.type): \(.message)"' compat.json || true + exit 1 + fi From 1823aa6e5729a59ab13be49562dffbbb53e4ae37 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 27 Oct 2026 13:01:26 +0700 Subject: [PATCH 2/5] ci: read the WIF identifiers from secrets or variables Signed-off-by: ajianaz --- .github/workflows/anthropic-wif-smoke.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/anthropic-wif-smoke.yml b/.github/workflows/anthropic-wif-smoke.yml index c8d68a0..14c2c13 100644 --- a/.github/workflows/anthropic-wif-smoke.yml +++ b/.github/workflows/anthropic-wif-smoke.yml @@ -22,19 +22,19 @@ jobs: timeout-minutes: 5 env: # Identifiers, not credentials, but this repository is public: they live - # in repository variables instead of the workflow file. - FEDERATION_RULE_ID: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} - ORGANIZATION_ID: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} - SERVICE_ACCOUNT_ID: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} - WORKSPACE_ID: ${{ vars.ANTHROPIC_WORKSPACE_ID }} - MODEL: ${{ vars.ANTHROPIC_MODEL || 'claude-opus-5-5' }} + # in organization/repository secrets or variables, not in this file. + FEDERATION_RULE_ID: ${{ secrets.ANTHROPIC_FEDERATION_RULE_ID || vars.ANTHROPIC_FEDERATION_RULE_ID }} + ORGANIZATION_ID: ${{ secrets.ANTHROPIC_ORGANIZATION_ID || vars.ANTHROPIC_ORGANIZATION_ID }} + SERVICE_ACCOUNT_ID: ${{ secrets.ANTHROPIC_SERVICE_ACCOUNT_ID || vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} + WORKSPACE_ID: ${{ secrets.ANTHROPIC_WORKSPACE_ID || vars.ANTHROPIC_WORKSPACE_ID }} + MODEL: ${{ secrets.ANTHROPIC_MODEL || vars.ANTHROPIC_MODEL || 'claude-opus-5-5' }} steps: - name: Require the federation variables run: | missing=0 for name in FEDERATION_RULE_ID ORGANIZATION_ID SERVICE_ACCOUNT_ID WORKSPACE_ID; do if [ -z "${!name}" ]; then - echo "::error::Repository variable ANTHROPIC_${name} is empty or missing." + echo "::error::Secret or variable ANTHROPIC_${name} is empty or missing." missing=1 fi done From ef0ee793537684ca2e0de259c2c2c5bcf099f404 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 27 Oct 2026 13:05:28 +0700 Subject: [PATCH 3/5] ci: print the OIDC claims in the WIF smoke test Signed-off-by: ajianaz --- .github/workflows/anthropic-wif-smoke.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/anthropic-wif-smoke.yml b/.github/workflows/anthropic-wif-smoke.yml index 14c2c13..5ea3ed7 100644 --- a/.github/workflows/anthropic-wif-smoke.yml +++ b/.github/workflows/anthropic-wif-smoke.yml @@ -48,6 +48,14 @@ jobs: core.setSecret(token); core.exportVariable('JWT', token); + - name: Show the OIDC claims a federation rule can match on + run: | + # Claims are identifiers (issuer, audience, subject, repository, ref), + # not secrets. The signature part of the token is never printed. + payload=$(printf '%s' "$JWT" | cut -d. -f2 | tr '_-' '/+') + while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="${payload}="; done + printf '%s' "$payload" | base64 -d | jq '{iss, aud, sub, repository, repository_owner, ref, event_name, job_workflow_ref}' + - name: Exchange it for an Anthropic access token run: | body=$(jq -n \ From 99dc8f44708c94effeaab0e37ee16532d5732bbc Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 27 Oct 2026 13:21:09 +0700 Subject: [PATCH 4/5] ci: use the single-step OIDC exchange in the WIF smoke test Signed-off-by: ajianaz --- .github/workflows/anthropic-wif-smoke.yml | 35 ++++++++++------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/.github/workflows/anthropic-wif-smoke.yml b/.github/workflows/anthropic-wif-smoke.yml index 5ea3ed7..3e3d50b 100644 --- a/.github/workflows/anthropic-wif-smoke.yml +++ b/.github/workflows/anthropic-wif-smoke.yml @@ -40,26 +40,22 @@ jobs: done exit "$missing" - - name: Fetch the GitHub OIDC token - uses: actions/github-script@v8 - with: - script: | - const token = await core.getIDToken('https://api.anthropic.com'); - core.setSecret(token); - core.exportVariable('JWT', token); - - - name: Show the OIDC claims a federation rule can match on + - name: Exchange the GitHub OIDC token for an Anthropic access token run: | - # Claims are identifiers (issuer, audience, subject, repository, ref), - # not secrets. The signature part of the token is never printed. - payload=$(printf '%s' "$JWT" | cut -d. -f2 | tr '_-' '/+') + # Same step as in cora-review.yml: the OIDC token stays in this shell. + jwt=$(curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.anthropic.com" | jq -r '.value // empty') + if [ -z "$jwt" ]; then + echo "::error::Could not get a GitHub OIDC token (is id-token: write set?)." + exit 1 + fi + echo "::add-mask::$jwt" + # Claims are identifiers (subject, repository, event), not secrets. + payload=$(printf '%s' "$jwt" | cut -d. -f2 | tr '_-' '/+') while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="${payload}="; done - printf '%s' "$payload" | base64 -d | jq '{iss, aud, sub, repository, repository_owner, ref, event_name, job_workflow_ref}' - - - name: Exchange it for an Anthropic access token - run: | + printf '%s' "$payload" | base64 -d | jq '{sub, repository, event_name}' body=$(jq -n \ - --arg jwt "$JWT" \ + --arg jwt "$jwt" \ --arg rule "$FEDERATION_RULE_ID" \ --arg org "$ORGANIZATION_ID" \ --arg sa "$SERVICE_ACCOUNT_ID" \ @@ -72,14 +68,13 @@ jobs: -H "content-type: application/json" --data "$body") echo "token exchange: HTTP $code" if [ "$code" != "200" ]; then - jq 'with_entries(if (.key | test("token$")) then .value = "" else . end)' exchange.json || true + jq -r '.error | "\(.type): \(.message)"' exchange.json || true exit 1 fi token=$(jq -r '.access_token' exchange.json) + rm -f exchange.json echo "::add-mask::$token" echo "ACCESS_TOKEN=$token" >> "$GITHUB_ENV" - echo "token_type=$(jq -r '.token_type' exchange.json) expires_in=$(jq -r '.expires_in' exchange.json)" - rm -f exchange.json - name: Call the OpenAI-compatible endpoint the way cora does run: | From 9979a66bf064f7f2b78ad6bce62591cfbb858805 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 27 Oct 2026 13:24:28 +0700 Subject: [PATCH 5/5] ci: make the WIF smoke test manual only Signed-off-by: ajianaz --- .github/workflows/anthropic-wif-smoke.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/anthropic-wif-smoke.yml b/.github/workflows/anthropic-wif-smoke.yml index 3e3d50b..3b9c366 100644 --- a/.github/workflows/anthropic-wif-smoke.yml +++ b/.github/workflows/anthropic-wif-smoke.yml @@ -4,12 +4,14 @@ name: Anthropic WIF smoke test # short-lived Anthropic access token (workload identity federation) and that # the token works the way cora sends it: `Authorization: Bearer ` to the # OpenAI-compatible `/chat/completions` endpoint. Prints HTTP statuses only, -# never a token. Runs when this file changes. +# never a token. +# +# Manual only (Actions tab -> Run workflow), so it costs nothing on normal PRs. +# The federation rule must allow this workflow's OIDC subject (for a manual run +# on a branch that is `...:ref:refs/heads/`, not `...:pull_request`). on: - pull_request: - paths: - - ".github/workflows/anthropic-wif-smoke.yml" + workflow_dispatch: permissions: id-token: write