2
0
Эх сурвалжийг харах

Gate cloud integration tests behind layered fork-PR protections

A prior attempted breach against pull_request_target exposed how
dangerous the previous workflow was: fork PRs ran arbitrary code with
read access to *every* cloud's secrets (not just the matrix cell they
claimed to touch). This commit replaces the single integration
workflow with a split architecture that makes the cloud-secret path
unreachable without explicit maintainer review at two points.

Architecture:

* `integration.yaml` runs lint + the mock provider only, on
  `pull_request`. It carries no secrets, so fork PRs run it freely
  without any human gate.
* `integration-cloud.yaml` runs the per-cloud matrix on
  `pull_request_target` with `types: [labeled]`, on push to main, and
  on workflow_dispatch. Fork PRs only enter this path after a
  maintainer applies the `safe-to-test` label.

Defence layers in integration-cloud.yaml:

1. `types: [labeled]` — only label-add fires the workflow; force-pushes
   do not re-trigger it.
2. `if: github.event.label.name == 'safe-to-test'` — only that one
   label, so applying unrelated labels does not run cloud tests.
3. `pr-label-strip.yaml` removes `safe-to-test` on every PR push, so
   the label only ever points at the SHA the maintainer reviewed.
4. The checkout pins to `github.event.pull_request.head.sha` from the
   immutable event payload, defeating force-push races between
   label-add and runner start.
5. The `cloud-integration` GitHub Environment requires reviewer
   approval per run before the OIDC token / cloud secrets are exposed.
6. The AWS trust policy now only accepts the
   `environment:cloud-integration` sub claim for PR runs. The previous
   `repo:CloudVE/cloudbridge:pull_request` claim was load-bearing for
   a fork-PR breach path — if `id-token: write` for fork PRs were ever
   enabled at the repo level, a fork could have minted an OIDC token
   AWS would have accepted. That claim is removed.

Cross-cloud secret scoping:

Each cloud's secrets in the tox `env:` block are now gated on
`matrix.cloud-provider`, so the AWS cell no longer sees Azure / GCP /
OpenStack credentials and vice versa. Limits blast radius if a single
matrix cell is compromised in the future.

Other changes folded in:

* Third-party actions pinned to SHAs:
  `schneegans/dynamic-badges-action@v1.8.0`,
  `AndreMiras/coveralls-python-action` to its 2024-09-26 develop SHA.
* First-party actions bumped to current latest majors:
  `actions/checkout@v6`, `actions/setup-python@v6`,
  `actions/cache@v5`, `aws-actions/configure-aws-credentials@v6`.
* `deploy.yaml`: removed `@master` mutable refs on
  `actions/checkout` and `pypa/gh-action-pypi-publish`; bumped
  `actions/setup-python` from v1; added top-level `contents: read`.
* `persist-credentials: false` on all checkouts so GITHUB_TOKEN is
  not written to .git/config and cannot leak to subsequent steps.
* The build-status badge condition was checking `refs/heads/master`,
  which has not existed since the default-branch rename — fixed to
  `refs/heads/main`.

Documentation:

`docs/topics/pull_request_ci.rst` describes the architecture, the
labeler audit checklist, and the one-time repo setup (label,
environment, OIDC role). Linked from `contributor_guide.rst`.

Setup required before this CI works:

* Create the `safe-to-test` label in the repo.
* Create the `cloud-integration` environment with required reviewers
  and `main` as the only deployment branch.
* Re-run `.github/aws/setup.sh` to push the tightened trust policy.
Nuwan Goonasekera 7 цаг өмнө
parent
commit
584d456d9f

+ 1 - 1
.github/aws/trust-policy.json

@@ -15,7 +15,7 @@
         "StringLike": {
           "token.actions.githubusercontent.com:sub": [
             "repo:CloudVE/cloudbridge:ref:refs/heads/main",
-            "repo:CloudVE/cloudbridge:pull_request"
+            "repo:CloudVE/cloudbridge:environment:cloud-integration"
           ]
         }
       }

+ 10 - 4
.github/workflows/deploy.yaml

@@ -5,14 +5,20 @@ on:
   push:
     tags:
       - '*'
+
+permissions:
+  contents: read
+
 jobs:
   build-n-publish:
     name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@master
+    - uses: actions/checkout@v6
+      with:
+        persist-credentials: false
     - name: Set up Python 3.10.12
-      uses: actions/setup-python@v1
+      uses: actions/setup-python@v6
       with:
         python-version: 3.10.12
     - name: Install dependencies
@@ -25,13 +31,13 @@ jobs:
         twine check dist/*
         ls -l dist
     - name: Publish distribution 📦 to Test PyPI
-      uses: pypa/gh-action-pypi-publish@master
+      uses: pypa/gh-action-pypi-publish@v1.14.0
       with:
         password: ${{ secrets.TEST_PYPI_API_TOKEN }}
         repository_url: https://test.pypi.org/legacy/
         skip_existing: true
     - name: Publish distribution 📦 to PyPI
       if: github.event_name == 'release'
-      uses: pypa/gh-action-pypi-publish@master
+      uses: pypa/gh-action-pypi-publish@v1.14.0
       with:
         password: ${{ secrets.PYPI_API_TOKEN }}

+ 154 - 0
.github/workflows/integration-cloud.yaml

@@ -0,0 +1,154 @@
+name: Cloud integration tests
+
+# Runs the per-cloud integration matrix. This workflow handles untrusted PR
+# code from forks, so it relies on layered protections:
+#
+#   1. Trigger is pull_request_target with types=[labeled] — fires only when a
+#      label is *added*. A force-push to the PR head does not re-fire this.
+#   2. The `safe-to-test` label is the only one that matters (see `if:` below).
+#      Pushing new commits to the PR causes pr-label-strip.yaml to remove the
+#      label, so re-testing requires a maintainer to re-label after re-review.
+#   3. The PR head SHA is taken from the event payload, which is captured at
+#      label-add time. Even if the attacker force-pushes between label-add and
+#      the runner starting, the checkout pins to the SHA the maintainer saw.
+#   4. The `cloud-integration` GitHub Environment requires reviewer approval
+#      per run. This is the last-line defense and is enforced by GitHub before
+#      the OIDC token / cloud secrets are exposed.
+#   5. The AWS trust policy only accepts the `environment:cloud-integration`
+#      sub claim for PR runs — so even if 1–4 were bypassed, a fork PR run
+#      without our environment cannot assume the role.
+#   6. Each cloud's secrets are gated by matrix.cloud-provider so a single
+#      compromised matrix cell cannot exfiltrate other clouds' credentials.
+#
+# Push to main and workflow_dispatch run without the label gate (the
+# Environment is conditionally skipped — they are trusted contexts).
+
+on:
+  pull_request_target:
+    types: [labeled]
+  push:
+    branches: [main]
+  workflow_dispatch: {}
+
+permissions:
+  contents: read
+
+jobs:
+  cloud:
+    name: Per-cloud integration tests
+    # Gate fork PR runs by the `safe-to-test` label. Push and workflow_dispatch
+    # don't carry a `github.event.label` so the right-hand branch fires.
+    if: >-
+      github.event_name != 'pull_request_target'
+      || github.event.label.name == 'safe-to-test'
+    runs-on: ubuntu-latest
+    # The environment is only set for pull_request_target — push and
+    # workflow_dispatch are trusted contexts and don't need a per-run approval.
+    environment: ${{ github.event_name == 'pull_request_target' && 'cloud-integration' || '' }}
+    permissions:
+      id-token: write   # required for AWS OIDC
+      contents: read
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ['3.10']
+        cloud-provider: ['aws', 'azure', 'gcp', 'openstack']
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v6
+        with:
+          # github.event.pull_request.head.sha is captured at trigger time and
+          # immutable in the event payload — pins to the SHA the maintainer
+          # reviewed when applying the label.
+          ref: ${{ github.event.pull_request.head.sha }}
+          persist-credentials: false
+
+      - name: Setup Python
+        uses: actions/setup-python@v6
+        with:
+           python-version: ${{ matrix.python-version }}
+
+      - name: Cache pip dir
+        uses: actions/cache@v5
+        with:
+          path: ~/.cache/pip
+          key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('**/setup.py', '**/requirements.txt') }}
+
+      - name: Install required packages
+        run: pip install tox
+
+      - name: Configure AWS credentials via OIDC
+        if: matrix.cloud-provider == 'aws'
+        uses: aws-actions/configure-aws-credentials@v6
+        with:
+          role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
+          aws-region: us-east-1
+
+      - name: Run tox
+        id: tox
+        run: tox -e py${{ matrix.python-version }}-${{ matrix.cloud-provider }}
+        env:
+          PYTHONUNBUFFERED: "True"
+          # Per-cloud secret scoping: each variable is only set in the matrix
+          # cell that needs it. Limits blast radius if a single cell is
+          # compromised.
+          # aws — credentials supplied via the OIDC step above
+          CB_VM_TYPE_AWS: ${{ matrix.cloud-provider == 'aws' && secrets.CB_VM_TYPE_AWS || '' }}
+          # azure
+          AZURE_CLIENT_ID: ${{ matrix.cloud-provider == 'azure' && secrets.AZURE_CLIENT_ID || '' }}
+          AZURE_SUBSCRIPTION_ID: ${{ matrix.cloud-provider == 'azure' && secrets.AZURE_SUBSCRIPTION_ID || '' }}
+          AZURE_SECRET: ${{ matrix.cloud-provider == 'azure' && secrets.AZURE_SECRET || '' }}
+          AZURE_TENANT: ${{ matrix.cloud-provider == 'azure' && secrets.AZURE_TENANT || '' }}
+          AZURE_RESOURCE_GROUP: ${{ matrix.cloud-provider == 'azure' && secrets.AZURE_RESOURCE_GROUP || '' }}
+          AZURE_STORAGE_ACCOUNT: ${{ matrix.cloud-provider == 'azure' && secrets.AZURE_STORAGE_ACCOUNT || '' }}
+          CB_IMAGE_AZURE: ${{ matrix.cloud-provider == 'azure' && secrets.CB_IMAGE_AZURE || '' }}
+          CB_VM_TYPE_AZURE: ${{ matrix.cloud-provider == 'azure' && secrets.CB_VM_TYPE_AZURE || '' }}
+          # gcp
+          GCP_SERVICE_CREDS_DICT: ${{ matrix.cloud-provider == 'gcp' && secrets.GCP_SERVICE_CREDS_DICT || '' }}
+          CB_IMAGE_GCP: ${{ matrix.cloud-provider == 'gcp' && secrets.CB_IMAGE_GCP || '' }}
+          CB_VM_TYPE_GCP: ${{ matrix.cloud-provider == 'gcp' && secrets.CB_VM_TYPE_GCP || '' }}
+          # openstack
+          OS_AUTH_URL: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_AUTH_URL || '' }}
+          OS_PASSWORD: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_PASSWORD || '' }}
+          OS_PROJECT_NAME: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_PROJECT_NAME || '' }}
+          OS_PROJECT_DOMAIN_NAME: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_PROJECT_DOMAIN_NAME || '' }}
+          OS_TENANT_NAME: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_TENANT_NAME || '' }}
+          OS_USERNAME: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_USERNAME || '' }}
+          OS_REGION_NAME: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_REGION_NAME || '' }}
+          OS_USER_DOMAIN_NAME: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_USER_DOMAIN_NAME || '' }}
+          OS_APPLICATION_CREDENTIAL_ID: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_APPLICATION_CREDENTIAL_ID || '' }}
+          OS_APPLICATION_CREDENTIAL_SECRET: ${{ matrix.cloud-provider == 'openstack' && secrets.OS_APPLICATION_CREDENTIAL_SECRET || '' }}
+          CB_IMAGE_OS: ${{ matrix.cloud-provider == 'openstack' && secrets.CB_IMAGE_OS || '' }}
+          CB_VM_TYPE_OS: ${{ matrix.cloud-provider == 'openstack' && secrets.CB_VM_TYPE_OS || '' }}
+          CB_PLACEMENT_OS: ${{ matrix.cloud-provider == 'openstack' && secrets.CB_PLACEMENT_OS || '' }}
+
+      - name: Create Build Status Badge
+        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+        uses: schneegans/dynamic-badges-action@0e50b8bad39e7e1afd3e4e9c2b7dd145fad07501 # v1.8.0
+        with:
+          auth: ${{ secrets.BUILD_STATUS_GIST_SECRET }}
+          gistID: ${{ secrets.BUILD_STATUS_GIST_ID }}
+          filename: cloudbridge_py${{ matrix.python-version }}_${{ matrix.cloud-provider }}.json
+          label: ${{ matrix.cloud-provider }}
+          message: ${{ fromJSON('["passing", "failing"]')[steps.tox.outcome != 'success'] }}
+          color: ${{ fromJSON('["green", "red"]')[steps.tox.outcome != 'success'] }}
+
+      - name: Coveralls
+        if: ${{ steps.tox.outcome == 'success' }}
+        uses: AndreMiras/coveralls-python-action@ac868b9540fad490f7ca82b8ca00480fd751ed19 # develop @ 2024-09-26
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          flag-name: run-${{ matrix.python-version }}-${{ matrix.cloud-provider }}
+          parallel: true
+
+  finish:
+    needs: cloud
+    if: ${{ always() && needs.cloud.result != 'skipped' }}
+    runs-on: ubuntu-latest
+    steps:
+    - name: Coveralls Finished
+      uses: AndreMiras/coveralls-python-action@ac868b9540fad490f7ca82b8ca00480fd751ed19 # develop @ 2024-09-26
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        parallel-finished: true

+ 24 - 91
.github/workflows/integration.yaml

@@ -1,18 +1,26 @@
-name: Integration tests
+name: Lint and mock tests
+
+# Runs on every push and pull request — including PRs from forks. This workflow
+# is intentionally limited to lint + the mock provider so it can run safely on
+# untrusted code (no secrets, no cloud credentials).
+#
+# Cloud-provider integration tests live in integration-cloud.yaml, which uses
+# pull_request_target plus a maintainer-applied `safe-to-test` label and a
+# protected GitHub Environment.
 
-# Run this workflow every time a new commit pushed to your repository
 on:
   push:
     branches:
     - main
-  pull_request_target:
+  pull_request:
     branches:
       - main
   workflow_dispatch: {}
 
+permissions:
+  contents: read
+
 jobs:
-  # Set the job key. The key is displayed as the job name
-  # when a job name is not provided
   lint:
     name: Lint code
     runs-on: ubuntu-latest
@@ -21,17 +29,17 @@ jobs:
         python-version: [ '3.10' ]
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@v6
         with:
-          ref: ${{ github.event.pull_request.head.sha }}
+          persist-credentials: false
 
       - name: Setup Python
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@v6
         with:
            python-version: ${{ matrix.python-version }}
 
       - name: Cache pip dir
-        uses: actions/cache@v4
+        uses: actions/cache@v5
         with:
           path: ~/.cache/pip
           key: pip-cache-${{ matrix.python-version }}-lint
@@ -42,34 +50,25 @@ jobs:
       - name: Run tox
         run: tox -e lint
 
-  integration:
-    # Name the Job
-    name: Per-cloud integration tests
-    needs: lint
-    # Set the type of machine to run on
+  mock:
+    name: Mock-provider tests
     runs-on: ubuntu-latest
-    permissions:
-      id-token: write   # required for AWS OIDC
-      contents: read
     strategy:
-      fail-fast: false
       matrix:
         python-version: ['3.10']
-        cloud-provider: ['aws', 'azure', 'gcp', 'mock', 'openstack']
-
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@v6
         with:
-          ref: ${{ github.event.pull_request.head.sha }}
+          persist-credentials: false
 
       - name: Setup Python
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@v6
         with:
            python-version: ${{ matrix.python-version }}
 
       - name: Cache pip dir
-        uses: actions/cache@v4
+        uses: actions/cache@v5
         with:
           path: ~/.cache/pip
           key: pip-cache-${{ matrix.python-version }}-${{ hashFiles('**/setup.py', '**/requirements.txt') }}
@@ -77,73 +76,7 @@ jobs:
       - name: Install required packages
         run: pip install tox
 
-      - name: Configure AWS credentials via OIDC
-        if: matrix.cloud-provider == 'aws'
-        uses: aws-actions/configure-aws-credentials@v4
-        with:
-          role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
-          aws-region: us-east-1
-
       - name: Run tox
-        id: tox
-        run: tox -e py${{ matrix.python-version }}-${{ matrix.cloud-provider }}
+        run: tox -e py${{ matrix.python-version }}-mock
         env:
           PYTHONUNBUFFERED: "True"
-          # aws — credentials provided by configure-aws-credentials step above (OIDC)
-          CB_VM_TYPE_AWS: ${{ secrets.CB_VM_TYPE_AWS }}
-          # azure
-          AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
-          AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
-          AZURE_SECRET: ${{ secrets.AZURE_SECRET }}
-          AZURE_TENANT: ${{ secrets.AZURE_TENANT }}
-          AZURE_RESOURCE_GROUP: ${{ secrets.AZURE_RESOURCE_GROUP }}
-          AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }}
-          CB_IMAGE_AZURE: ${{ secrets.CB_IMAGE_AZURE }}
-          CB_VM_TYPE_AZURE: ${{ secrets.CB_VM_TYPE_AZURE }}
-          # gcp
-          GCP_SERVICE_CREDS_DICT: ${{ secrets.GCP_SERVICE_CREDS_DICT }}
-          CB_IMAGE_GCP: ${{ secrets.CB_IMAGE_GCP }}
-          CB_VM_TYPE_GCP: ${{ secrets.CB_VM_TYPE_GCP }}
-          # openstack
-          OS_AUTH_URL: ${{ secrets.OS_AUTH_URL }}
-          OS_PASSWORD: ${{ secrets.OS_PASSWORD }}
-          OS_PROJECT_NAME: ${{ secrets.OS_PROJECT_NAME }}
-          OS_PROJECT_DOMAIN_NAME: ${{ secrets.OS_PROJECT_DOMAIN_NAME }}
-          OS_TENANT_NAME: ${{ secrets.OS_TENANT_NAME }}
-          OS_USERNAME: ${{ secrets.OS_USERNAME }}
-          OS_REGION_NAME: ${{ secrets.OS_REGION_NAME }}
-          OS_USER_DOMAIN_NAME: ${{ secrets.OS_USER_DOMAIN_NAME }}
-          OS_APPLICATION_CREDENTIAL_ID: ${{ secrets.OS_APPLICATION_CREDENTIAL_ID }}
-          OS_APPLICATION_CREDENTIAL_SECRET: ${{ secrets.OS_APPLICATION_CREDENTIAL_SECRET }}
-          CB_IMAGE_OS: ${{ secrets.CB_IMAGE_OS }}
-          CB_VM_TYPE_OS: ${{ secrets.CB_VM_TYPE_OS }}
-          CB_PLACEMENT_OS: ${{ secrets.CB_PLACEMENT_OS }}
-
-      - name: Create Build Status Badge
-        if: github.ref == 'refs/heads/master'
-        uses: schneegans/dynamic-badges-action@v1.1.0
-        with:
-          auth: ${{ secrets.BUILD_STATUS_GIST_SECRET }}
-          gistID: ${{ secrets.BUILD_STATUS_GIST_ID }}
-          filename: cloudbridge_py${{ matrix.python-version }}_${{ matrix.cloud-provider }}.json
-          label: ${{ matrix.cloud-provider }}
-          message: ${{ fromJSON('["passing", "failing"]')[steps.tox.outcome != 'success'] }}
-          color: ${{ fromJSON('["green", "red"]')[steps.tox.outcome != 'success'] }}
-
-      - name: Coveralls
-        if: ${{ steps.tox.outcome == 'success' }}
-        uses: AndreMiras/coveralls-python-action@develop
-        with:
-          github-token: ${{ secrets.GITHUB_TOKEN }}
-          flag-name: run-${{ matrix.python-version }}-${{ matrix.cloud-provider }}
-          parallel: true
-
-  finish:
-    needs: integration
-    runs-on: ubuntu-latest
-    steps:
-    - name: Coveralls Finished
-      uses: AndreMiras/coveralls-python-action@develop
-      with:
-        github-token: ${{ secrets.github_token }}
-        parallel-finished: true

+ 26 - 0
.github/workflows/pr-label-strip.yaml

@@ -0,0 +1,26 @@
+name: Strip safe-to-test on PR update
+
+# When new commits land on a PR, automatically remove the `safe-to-test` label
+# so the cloud integration workflow cannot be re-triggered against unreviewed
+# code. A maintainer must re-apply the label after reviewing the new diff.
+
+on:
+  pull_request_target:
+    types: [synchronize]
+
+permissions:
+  pull-requests: write
+
+jobs:
+  strip:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Remove safe-to-test label
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_REPO: ${{ github.repository }}
+          PR_NUMBER: ${{ github.event.pull_request.number }}
+        # `gh pr edit --remove-label` exits non-zero if the label isn't
+        # present; that's the common case (most PRs are never labeled), so
+        # swallow it.
+        run: gh pr edit "$PR_NUMBER" --remove-label safe-to-test || true

+ 1 - 0
docs/topics/contributor_guide.rst

@@ -10,6 +10,7 @@ CloudBridge Provider.
     Design Goals <design_goals.rst>
     Design Decisions <design_decisions.rst>
     Testing <testing.rst>
+    Pull Request CI <pull_request_ci.rst>
     Provider Development Walkthrough <provider_development.rst>
     Release Process <release_process.rst>
 

+ 92 - 0
docs/topics/pull_request_ci.rst

@@ -0,0 +1,92 @@
+Pull request CI and security gates
+==================================
+Two CI workflows run on pull requests, with different trust levels:
+
+.. list-table::
+   :header-rows: 1
+   :widths: 25 25 20 30
+
+   * - Workflow
+     - Trigger
+     - Runs on forks?
+     - Secrets exposed
+   * - ``Lint and mock tests`` (``integration.yaml``)
+     - every PR / push
+     - yes, always
+     - none
+   * - ``Cloud integration tests`` (``integration-cloud.yaml``)
+     - ``safe-to-test`` label, push to ``main``, manual dispatch
+     - only after maintainer approval
+     - AWS / Azure / GCP / OpenStack
+
+Fork PRs always get lint + mock feedback automatically. Cloud integration
+tests are gated behind maintainer review because they run untrusted PR code
+with access to live cloud credentials.
+
+
+For maintainers: applying ``safe-to-test``
+------------------------------------------
+Cloud integration runs against PRs are gated by the ``safe-to-test`` label.
+**Adding this label is equivalent to authorising arbitrary code execution
+against our cloud accounts.** Before applying it on a PR from a fork, audit
+the diff for anything that runs at install or test time:
+
+* ``setup.py`` / ``setup.cfg`` / ``pyproject.toml`` — any new ``cmdclass``,
+  ``entry_points``, post-install hooks, or ``extras_require`` entries that
+  pull in unfamiliar packages?
+* ``tox.ini`` — any new env definitions, ``commands`` overrides, or
+  ``setenv`` injections?
+* ``conftest.py`` and any ``__init__.py`` under ``tests/`` — these run on
+  pytest startup before fixtures even decide to run.
+* New files anywhere under ``.github/`` — workflow tampering.
+* Any new test that does outbound network IO outside the expected cloud
+  APIs (e.g., raw ``requests.post`` to an arbitrary URL).
+* Any change under ``cloudbridge/`` that calls ``subprocess``, ``os.system``,
+  ``eval``, ``exec``, or writes to disk outside the test working tree.
+
+After labeling, the workflow queues and stops at the ``cloud-integration``
+environment gate — you will get a second prompt to approve the actual run.
+Treat that as a sanity check, not the primary defence; the label was the
+real authorisation moment.
+
+If the PR is updated after labeling, the label is automatically removed by
+``pr-label-strip.yaml``. To re-test, re-audit the new diff before
+re-applying.
+
+
+One-time repo setup
+-------------------
+If you are setting up CI from scratch on a fork or new repo, these one-time
+configurations are required:
+
+
+``safe-to-test`` label
+~~~~~~~~~~~~~~~~~~~~~~
+Create the label in the repo's Issues → Labels page. Any colour. Restrict
+label management to maintainers (default for org-owned repos).
+
+
+``cloud-integration`` environment
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+1. Repo Settings → Environments → New environment → name it
+   ``cloud-integration``.
+2. Under **Required reviewers**, add the maintainer team or users who are
+   allowed to approve cloud-integration runs.
+3. Under **Deployment branches**, select **Selected branches** and add
+   ``main`` (the workflow only ever runs from base context).
+4. The environment does not need any environment-scoped secrets — all
+   secrets live at the repo level.
+
+
+AWS OIDC role
+~~~~~~~~~~~~~
+See ``.github/aws/setup.sh``. The role's trust policy
+(``.github/aws/trust-policy.json``) accepts two sub claims:
+
+* ``repo:CloudVE/cloudbridge:ref:refs/heads/main`` — push-to-main runs.
+* ``repo:CloudVE/cloudbridge:environment:cloud-integration`` — PR runs that
+  reached the protected environment. Fork PRs that do not reach the
+  environment cannot assume this role.
+
+The repo secret ``AWS_OIDC_ROLE_ARN`` must be set to the role ARN printed by
+``setup.sh``.