Skip to content

Commit 667412b

Browse files
alucardzombsgrigorovcursoragent
authored
fix(INFRA-3631): add job-level permissions to shadow CI caller (#30252)
## **Description** This PR finishes **INFRA-3631** Namespace shadow CI work in two parts: **1. `workflow_call` permission cap (startup fix)** With `workflow_call`, the caller job’s permissions cap the callee. The `shadow-ci` caller job in `ci.yml` now declares the permissions downstream jobs need (`id-token`, `statuses`, `issues`, `pull-requests`, etc.) so shadow runs do not hit `startup_failure` (see TEC-54198 / prior validation runs in this thread). **2. Token Exchange for shadow dispatch (latest)** The `ci-namespace-shadow.yml` dispatcher no longer uses a dedicated GitHub App (`create-github-app-token`). It follows the same pattern as `triage-forwarder.yml`: **OIDC** (`id-token: write`, audience `api://token-exchange-service`) → **`POST $TOKEN_EXCHANGE_URL/api/exchange/token`** with `targetRepo` = this repo and scoped `requested_permissions` (`metadata`/`contents` read, `actions` write). - **TES policy** (binds minted tokens to this workflow file via GitHub OIDC claim **`workflow_ref`**, not `job_workflow_ref`): deploy **[token-exchange-service#77](consensys-vertical-apps/token-exchange-service#77 before relying on exchange in production. - **Fork PRs**: the dispatcher job is skipped when `pull_request.head.repo != github.repository`, so OIDC exchange never runs for untrusted forks. - **Duplicate side effects**: `ci.yml` gates status/comment/bundle steps when `runner_provider=namespace` so shadow runs stay read-mostly at the GitHub API layer. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [INFRA-3631](https://consensyssoftware.atlassian.net/browse/INFRA-3631) Related: TEC-54198 (TechOps — `workflow_call` permission inheritance) Token exchange policy PR: [consensys-vertical-apps/token-exchange-service#77](consensys-vertical-apps/token-exchange-service#77) ## **Manual testing steps** ```gherkin Feature: Namespace shadow CI dispatcher Scenario: Shadow workflow uses token exchange after TES deploy Given TOKEN_EXCHANGE_URL is set and TES policy from PR #77 is deployed When a non-fork pull_request triggers ci-namespace-shadow.yml Then the dispatch job exchanges OIDC for a token and successfully workflow_dispatch'es ci.yml with runner_provider=namespace Scenario: Fork PR does not call token exchange Given a pull request from a fork head repository When ci-namespace-shadow.yml runs Then the dispatch job is skipped and no exchange request is made ``` ## **Screenshots/Recordings** N/A (CI / GitHub Actions only.) ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes GitHub Actions authentication and dispatch flow for shadow CI and conditions out status/comment/publishing steps when running on `namespace`, which could affect CI observability or external integrations if misconfigured. > > **Overview** > Reworks the Namespace shadow CI workflow to be **fire-and-forget** by dispatching `ci.yml` via `workflow_dispatch` instead of calling it directly, so shadow flakes don’t appear as PR checks or block the merge queue. > > Adds OIDC-based Token Exchange Service authentication (scoped `actions: write` token) for the dispatcher, skips the dispatcher entirely for fork PRs, and posts a step summary linking the originating PR to the dispatched run. > > Updates `ci.yml` to accept optional `pr_number`/`head_sha` inputs (used for `run-name` correlation) and to **disable side-effecting behavior** on `runner_provider=namespace` (e.g., commit status publishing, bundle-size shipping, PR comments, fixture-validation reporting) to avoid duplicate statuses/comments and external pushes. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e65e564. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [INFRA-3631]: https://consensyssoftware.atlassian.net/browse/INFRA-3631?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Borislav Grigorov <11405770+bsgrigorov@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5bfd071 commit 667412b

2 files changed

Lines changed: 178 additions & 13 deletions

File tree

Lines changed: 152 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,168 @@
11
name: CI (Namespace shadow)
22

3+
# Fire-and-forget dispatcher for the Namespace shadow benchmark (INFRA-3631).
4+
#
5+
# This workflow does NOT use `workflow_call`. Instead, it dispatches `ci.yml`
6+
# as a separate `workflow_dispatch` run with `runner_provider=namespace`.
7+
#
8+
# Why a dispatch instead of a call:
9+
# - `workflow_call` nests the called workflow's jobs into THIS workflow's
10+
# check suite on the PR. With the repo's merge queue ALLGREEN policy,
11+
# any shadow flake would block merges.
12+
# - `workflow_dispatch` runs live in the Actions tab only -- invisible to
13+
# PR checks. The shadow can fail freely; it never blocks the queue,
14+
# never duplicates commit statuses, never posts PR comments.
15+
#
16+
# Correlation back to the originating PR:
17+
# - The dispatched run's display name is set via `run-name` in ci.yml to
18+
# `ci [shadow PR #<num> @ <sha>]`, so it's identifiable in the Actions
19+
# tab at a glance.
20+
# - The dispatcher job posts a GitHub Actions step summary linking the PR
21+
# to the dispatched shadow run URL, reachable from the PR Checks tab.
22+
#
23+
# Authentication: Token Exchange Service (same pattern as triage-forwarder.yml
24+
# and shared-services-workflows deploy.yml — OIDC → POST /api/exchange/token).
25+
#
26+
# Prerequisites:
27+
# - Repo/org Actions variable: TOKEN_EXCHANGE_URL (already used elsewhere in
28+
# metamask-mobile, e.g. triage-forwarder).
29+
# - Rego policy in token-exchange-service: explicit policy
30+
# mm-metamask-mobile-namespace-shadow-ci-token-exchange (matches OIDC
31+
# `workflow_ref` claim for .github/workflows/ci-namespace-shadow.yml).
32+
#
33+
# Fork PRs: the job `if` below skips the entire job for fork head repos, so OIDC
34+
# exchange never runs for untrusted forks (same model as not exposing secrets).
35+
336
on:
437
pull_request:
538
types: [opened, synchronize, reopened, ready_for_review]
639
paths-ignore:
7-
- 'docs/**'
8-
- '**/*.md'
9-
- '.github/CODEOWNERS'
40+
- "docs/**"
41+
- "**/*.md"
42+
- ".github/CODEOWNERS"
1043
push:
1144
branches: [main]
1245
schedule:
13-
- cron: '0 * * * *'
46+
- cron: "0 * * * *"
1447
workflow_dispatch:
1548

1649
concurrency:
1750
group: ns-shadow-${{ github.workflow }}-${{ github.ref }}
1851
cancel-in-progress: true
1952

53+
permissions:
54+
contents: read
55+
id-token: write
56+
2057
jobs:
21-
shadow-ci:
22-
name: '[shadow] CI'
23-
uses: ./.github/workflows/ci.yml
24-
with:
25-
runner_provider: namespace
26-
secrets: inherit
58+
dispatch-shadow:
59+
name: "[shadow] Dispatch"
60+
runs-on: ubuntu-latest
61+
# Fork PRs use head.repo != github.repository — skip (no shadow, no token exchange).
62+
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
63+
steps:
64+
- name: Get OIDC token for token-exchange-service
65+
id: oidc
66+
run: |
67+
set -euo pipefail
68+
OIDC_TOKEN=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
69+
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api://token-exchange-service" | jq -r '.value')
70+
echo "::add-mask::$OIDC_TOKEN"
71+
echo "oidc_token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT"
72+
73+
- name: Exchange for installation token (scoped permissions)
74+
id: exchange
75+
env:
76+
OIDC_TOKEN: ${{ steps.oidc.outputs.oidc_token }}
77+
TOKEN_EXCHANGE_URL: ${{ vars.TOKEN_EXCHANGE_URL }}
78+
run: |
79+
set -euo pipefail
80+
if [ -z "${TOKEN_EXCHANGE_URL}" ]; then
81+
echo "::error::TOKEN_EXCHANGE_URL Actions variable is not set. Configure it at org or repo level (see triage-forwarder.yml)."
82+
exit 1
83+
fi
84+
RESPONSE=$(curl -sSf -X POST "${TOKEN_EXCHANGE_URL}/api/exchange/token" \
85+
-H "Content-Type: application/json" \
86+
-d "$(jq -cn \
87+
--arg oidcToken "$OIDC_TOKEN" \
88+
--arg targetRepo "${{ github.repository }}" \
89+
'{oidcToken: $oidcToken, targetRepo: $targetRepo, requested_permissions: {metadata: "read", contents: "read", actions: "write"}}')")
90+
STATUS=$(echo "$RESPONSE" | jq -r '.status // "ok"')
91+
if [[ "$STATUS" == "fail" ]]; then
92+
MSG=$(echo "$RESPONSE" | jq -r '.message // "unknown"')
93+
echo "::error::Token exchange failed: ${MSG}"
94+
echo "::notice::Ensure token-exchange-service policy mm-metamask-mobile-namespace-shadow-ci-token-exchange is deployed (INFRA-3631)."
95+
exit 1
96+
fi
97+
TOKEN=$(echo "$RESPONSE" | jq -r '.token // empty')
98+
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
99+
echo "::error::Token exchange returned no token"
100+
exit 1
101+
fi
102+
echo "::add-mask::$TOKEN"
103+
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
104+
105+
- name: Dispatch ci.yml on Namespace
106+
id: dispatch
107+
env:
108+
GH_TOKEN: ${{ steps.exchange.outputs.token }}
109+
REPO: ${{ github.repository }}
110+
REF: ${{ github.head_ref || github.ref_name }}
111+
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
112+
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
113+
run: |
114+
set -euo pipefail
115+
echo "Dispatching ci.yml on ref='${REF}' (PR='${PR_NUMBER:-none}', sha='${HEAD_SHA}')"
116+
gh workflow run ci.yml \
117+
--repo "$REPO" \
118+
--ref "$REF" \
119+
-f runner_provider=namespace \
120+
-f pr_number="$PR_NUMBER" \
121+
-f head_sha="$HEAD_SHA"
122+
123+
# gh workflow run returns immediately with no run ID. Find the run
124+
# we just created so we can link to it from the step summary.
125+
# Best-effort: poll briefly for a queued/in_progress dispatch on this ref.
126+
RUN_URL=""
127+
for _ in $(seq 1 10); do
128+
RUN_URL=$(gh run list \
129+
--repo "$REPO" \
130+
--workflow ci.yml \
131+
--event workflow_dispatch \
132+
--branch "$REF" \
133+
--limit 1 \
134+
--json url,createdAt \
135+
--jq '.[0].url' 2>/dev/null || true)
136+
if [ -n "$RUN_URL" ] && [ "$RUN_URL" != "null" ]; then
137+
break
138+
fi
139+
sleep 2
140+
done
141+
echo "run_url=${RUN_URL}" >> "$GITHUB_OUTPUT"
142+
143+
- name: Step summary
144+
env:
145+
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
146+
PR_URL: ${{ github.event.pull_request.html_url || '' }}
147+
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
148+
REF: ${{ github.head_ref || github.ref_name }}
149+
RUN_URL: ${{ steps.dispatch.outputs.run_url }}
150+
run: |
151+
{
152+
echo "## Namespace shadow CI dispatched"
153+
echo
154+
echo "| Field | Value |"
155+
echo "|---|---|"
156+
if [ -n "$PR_NUMBER" ]; then
157+
echo "| PR | [#${PR_NUMBER}](${PR_URL}) |"
158+
fi
159+
echo "| Ref | \`${REF}\` |"
160+
echo "| Head SHA | \`${HEAD_SHA}\` |"
161+
if [ -n "${RUN_URL}" ]; then
162+
echo "| Shadow run | ${RUN_URL} |"
163+
else
164+
echo "| Shadow run | (not yet visible — check the Actions tab) |"
165+
fi
166+
echo
167+
echo "_Shadow CI runs **fire-and-forget**: it does not appear in this PR's checks and never blocks the merge queue. Benchmark data is collected by \`scripts/namespace-benchmark.sh\`._"
168+
} >> "$GITHUB_STEP_SUMMARY"

.github/workflows/ci.yml

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
name: ci
22

3+
run-name: >-
4+
${{ inputs.pr_number && format('ci [shadow PR #{0} @ {1}]', inputs.pr_number, inputs.head_sha) || '' }}
5+
36
on:
47
push:
58
branches: [main]
@@ -19,6 +22,14 @@ on:
1922
type: string
2023
required: false
2124
default: current
25+
pr_number:
26+
description: "PR number (shadow correlation, optional)"
27+
type: string
28+
required: false
29+
head_sha:
30+
description: "PR head SHA (shadow correlation, optional)"
31+
type: string
32+
required: false
2233
workflow_dispatch:
2334
inputs:
2435
runner_provider:
@@ -29,6 +40,14 @@ on:
2940
- current
3041
- namespace
3142
default: current
43+
pr_number:
44+
description: "PR number (shadow correlation, optional)"
45+
required: false
46+
type: string
47+
head_sha:
48+
description: "PR head SHA (shadow correlation, optional)"
49+
required: false
50+
type: string
3251

3352
concurrency:
3453
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
@@ -124,6 +143,7 @@ jobs:
124143
retry_wait_seconds: 30
125144
command: yarn install --immutable
126145
- name: Post build-source-hash commit status
146+
if: ${{ inputs.runner_provider != 'namespace' }}
127147
id: publish
128148
uses: ./.github/actions/post-build-source-hash
129149
with:
@@ -466,7 +486,7 @@ jobs:
466486
EOF
467487
468488
- name: Post iOS JS bundle size to commit status
469-
if: ${{ github.ref == 'refs/heads/main' }}
489+
if: ${{ github.ref == 'refs/heads/main' && inputs.runner_provider != 'namespace' }}
470490
env:
471491
GITHUB_TOKEN: ${{ github.token }}
472492
shell: bash
@@ -515,7 +535,9 @@ jobs:
515535
name: Ship JS bundle size check
516536
runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
517537
needs: [js-bundle-size-check]
518-
if: ${{ github.ref == 'refs/heads/main' }}
538+
# Skip on Namespace shadow runs: hourly cron against main would otherwise
539+
# push duplicate entries to the external mobile_bundlesize_stats repo.
540+
if: ${{ github.ref == 'refs/heads/main' && inputs.runner_provider != 'namespace' }}
519541
steps:
520542
- uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
521543
if: ${{ inputs.runner_provider == 'namespace' }}
@@ -975,7 +997,7 @@ jobs:
975997
github-token: ${{ github.token }}
976998
pr-number: ${{ github.event.pull_request.number }}
977999
repository: ${{ github.repository }}
978-
post-comment: 'true'
1000+
post-comment: ${{ inputs.runner_provider != 'namespace' }}
9791001
base-ref: ${{ github.event.pull_request.base.ref }}
9801002

9811003
build-android-apks:
@@ -1115,6 +1137,7 @@ jobs:
11151137
path: fixture-results/
11161138

11171139
- name: Report results
1140+
if: ${{ inputs.runner_provider != 'namespace' }}
11181141
env:
11191142
RESULTS_PATH: fixture-results
11201143
VALIDATION_RESULT: ${{ needs.validate-e2e-fixtures.result }}

0 commit comments

Comments
 (0)