Skip to content

Commit 9f1a7b2

Browse files
authored
Move Cloudflare token into protected production environment
Require a successful post-copy production deployment and both smoke tests before removing the repository secret.\n\nCo-Authored-By: GPT-6 Sol <agent@openai.com>
1 parent ac8389a commit 9f1a7b2

4 files changed

Lines changed: 292 additions & 1 deletion

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
name: Move Cloudflare token to production environment
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
operation:
7+
description: Copy the token or remove the repository copy after a verified deployment
8+
required: true
9+
type: choice
10+
options:
11+
- copy
12+
- remove
13+
deployment_run_id:
14+
description: Successful post-copy Deploy to Cloudflare run ID (required for remove)
15+
required: false
16+
type: string
17+
18+
permissions:
19+
actions: read
20+
contents: read
21+
22+
concurrency:
23+
group: migrate-cloudflare-token
24+
cancel-in-progress: false
25+
26+
jobs:
27+
migrate:
28+
if: github.repository_id == '957245447' && github.ref == 'refs/heads/main'
29+
runs-on: ubuntu-latest
30+
timeout-minutes: 10
31+
environment: production
32+
env:
33+
GH_TOKEN: ${{ secrets.CLOUDFLARE_MIGRATION_PAT }}
34+
TARGET_REPOSITORY: getsentry/toolkit
35+
steps:
36+
- name: Validate repository and protected environment
37+
shell: bash
38+
run: |
39+
set -euo pipefail
40+
: "${GH_TOKEN:?CLOUDFLARE_MIGRATION_PAT is not configured}"
41+
test "$GITHUB_REPOSITORY" = "$TARGET_REPOSITORY"
42+
test "$(gh api "repos/$TARGET_REPOSITORY" --jq .id)" = '957245447'
43+
test "$(gh api "repos/$TARGET_REPOSITORY/environments/production" --jq '.deployment_branch_policy | "\(.protected_branches):\(.custom_branch_policies)"')" = 'false:true'
44+
test "$(gh api "repos/$TARGET_REPOSITORY/environments/production/deployment-branch-policies" --jq '[.branch_policies[] | "\(.type):\(.name)"] | join(",")')" = 'branch:main'
45+
46+
- name: Copy repository token to protected production
47+
if: inputs.operation == 'copy'
48+
env:
49+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
50+
shell: bash
51+
run: |
52+
set -euo pipefail
53+
set +x
54+
: "${CLOUDFLARE_API_TOKEN:?Repository CLOUDFLARE_API_TOKEN is not configured}"
55+
test "$(gh secret list --repo "$TARGET_REPOSITORY" --json name --jq '[.[].name | select(. == "CLOUDFLARE_API_TOKEN")] | length')" = '1'
56+
test "$(gh secret list --repo "$TARGET_REPOSITORY" --env production --json name --jq '[.[].name | select(. == "CLOUDFLARE_API_TOKEN")] | length')" = '0'
57+
printf '%s' "$CLOUDFLARE_API_TOKEN" | gh secret set CLOUDFLARE_API_TOKEN --repo "$TARGET_REPOSITORY" --env production --app actions
58+
test "$(gh secret list --repo "$TARGET_REPOSITORY" --env production --json name --jq '[.[].name | select(. == "CLOUDFLARE_API_TOKEN")] | length')" = '1'
59+
60+
- name: Validate successful deployment after environment copy
61+
if: inputs.operation == 'remove'
62+
env:
63+
DEPLOYMENT_RUN_ID: ${{ inputs.deployment_run_id }}
64+
RUN_TOKEN: ${{ github.token }}
65+
shell: bash
66+
run: |
67+
set -euo pipefail
68+
[[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]{0,17}$ ]]
69+
test "$(gh secret list --repo "$TARGET_REPOSITORY" --env production --json name --jq '[.[].name | select(. == "CLOUDFLARE_API_TOKEN")] | length')" = '1'
70+
copied_at="$(gh api "repos/$TARGET_REPOSITORY/environments/production/secrets/CLOUDFLARE_API_TOKEN" --jq .updated_at)"
71+
deploy_workflow_id="$(GH_TOKEN="$RUN_TOKEN" gh api "repos/$TARGET_REPOSITORY/actions/workflows/deploy.yml" --jq .id)"
72+
GH_TOKEN="$RUN_TOKEN" gh api "repos/$TARGET_REPOSITORY/actions/runs/$DEPLOYMENT_RUN_ID" --jq '
73+
[.workflow_id, .event, .head_branch, .head_repository.id, .status, .conclusion, .created_at, .run_attempt] | @tsv
74+
' | {
75+
IFS=$'\t' read -r workflow_id event branch repository_id status conclusion created_at run_attempt
76+
test "$workflow_id" = "$deploy_workflow_id"
77+
test "$event" = workflow_run
78+
test "$branch" = main
79+
test "$repository_id" = '957245447'
80+
test "$status" = completed
81+
test "$conclusion" = success
82+
[[ "$created_at" > "$copied_at" ]]
83+
[[ "$run_attempt" =~ ^[1-9][0-9]*$ ]]
84+
GH_TOKEN="$RUN_TOKEN" gh api "repos/$TARGET_REPOSITORY/actions/runs/$DEPLOYMENT_RUN_ID/attempts/$run_attempt/jobs?per_page=100" | jq -e --argjson attempt "$run_attempt" '
85+
.total_count == 1 and
86+
(.jobs | type == "array" and length == 1) and
87+
(.jobs[0] |
88+
.name == "Deploy to Cloudflare" and
89+
.run_attempt == $attempt and
90+
.status == "completed" and
91+
.conclusion == "success" and
92+
(. as $job | [
93+
"Deploy to Canary Worker",
94+
"Run Smoke Tests on Canary",
95+
"Deploy to Production Worker",
96+
"Verify production deployment ownership",
97+
"Run Smoke Tests on Production"
98+
] | all(.[]; . as $name | ($job.steps | map(select(.name == $name and .status == "completed" and .conclusion == "success")) | length) == 1))
99+
)
100+
' > /dev/null
101+
}
102+
103+
- name: Remove repository token and temporary migration credential
104+
if: inputs.operation == 'remove'
105+
shell: bash
106+
run: |
107+
set -euo pipefail
108+
set +x
109+
gh secret delete CLOUDFLARE_API_TOKEN --repo "$TARGET_REPOSITORY" --app actions
110+
test "$(gh secret list --repo "$TARGET_REPOSITORY" --json name --jq '[.[].name | select(. == "CLOUDFLARE_API_TOKEN")] | length')" = '0'
111+
test "$(gh secret list --repo "$TARGET_REPOSITORY" --env production --json name --jq '[.[].name | select(. == "CLOUDFLARE_API_TOKEN")] | length')" = '1'
112+
gh secret delete CLOUDFLARE_MIGRATION_PAT --repo "$TARGET_REPOSITORY" --env production --app actions

‎docs/operations/github-actions.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ tested commit, and verifies the run-owned candidate before production smoke
2626
tests. If those fail, restores the captured version only while this run's
2727
candidate remains active. External changes stop recovery.
2828

29+
### migrate-cloudflare-token.yml
30+
Moves the Cloudflare API token from a repository secret into the protected
31+
`production` environment. Only `main` in the Toolkit repository can run it.
32+
Copy and removal are separate dispatches so a normal production deployment can
33+
prove that the environment copy works before the repository copy is deleted.
34+
2935
### eval.yml
3036
Runs evaluation tests against the MCP server.
3137

@@ -53,6 +59,11 @@ The `production` environment is restricted to `main` and holds:
5359

5460
- **`CLOUDFLARE_API_TOKEN`** - Cloudflare API token with Workers deployment permissions
5561

62+
During migration, the same name also exists as a repository secret. A temporary
63+
`CLOUDFLARE_MIGRATION_PAT` environment secret gives the migration workflow
64+
permission to write environment secrets and delete the repository copy. The
65+
workflow deletes this PAT secret after successful cleanup.
66+
5667
Other configuration:
5768

5869
- **`CLOUDFLARE_ACCOUNT_ID`** - ID of the account owning the Workers
@@ -89,6 +100,30 @@ Manual production dispatch is unavailable. Use a reviewed change and its
89100
passing `Test` run to deploy. Never run bare `wrangler rollback` against
90101
production; that command chooses from mutable history.
91102

103+
## Cloudflare token migration
104+
105+
1. Merge the reviewed migration workflow into `main`. Create a fine-grained PAT
106+
for `getsentry/toolkit` with repository **Secrets: read/write** permission.
107+
Store it as `CLOUDFLARE_MIGRATION_PAT` in the protected `production`
108+
environment. Never pass the PAT in a workflow input or command argument.
109+
2. Dispatch `Move Cloudflare token to production environment` on `main` with
110+
`operation=copy`. Confirm success and check that `CLOUDFLARE_API_TOKEN`
111+
appears in both repository and `production` environment secret-name lists.
112+
3. Wait for a normal `Test` push on `main` to trigger `Deploy to Cloudflare`.
113+
Confirm that the deployment passed canary and production smoke tests and
114+
served the intended revision. Record its workflow run ID; its start time
115+
must be after the environment secret was copied.
116+
4. Dispatch the migration workflow again on `main` with `operation=remove` and
117+
that successful deployment run ID. The workflow checks the run's identity,
118+
result, timing, and successful canary and production deployment and smoke-test
119+
steps, then deletes the repository-scoped Cloudflare token.
120+
Check that only the environment copy remains and the temporary PAT secret
121+
has been removed. Revoke the PAT after use.
122+
123+
If a step fails, keep the repository copy until a successful post-copy
124+
deployment has been verified. Never print either credential while diagnosing
125+
the failure.
126+
92127
## Troubleshooting
93128

94129
1. **Authentication failed** - Check `CLOUDFLARE_API_TOKEN` permissions

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
},
2424
"scripts": {
2525
"docs:check": "node scripts/check-doc-links.mjs",
26-
"test:ci-projects": "node --test scripts/ci-projects.test.mjs scripts/cli-nightly-version.test.mjs scripts/cloudflare-deployment.test.mjs scripts/deploy-workflow.test.mjs",
26+
"test:ci-projects": "node --test scripts/ci-projects.test.mjs scripts/cli-nightly-version.test.mjs scripts/cloudflare-deployment.test.mjs scripts/deploy-workflow.test.mjs scripts/migrate-cloudflare-token.test.mjs",
2727
"dev": "pnpm --filter '@sentry/mcp-cloudflare...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-cloudflare --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev",
2828
"dev:stdio": "pnpm --filter '@sentry/mcp-server...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-server --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev",
2929
"build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build",
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import assert from "node:assert/strict";
2+
import { spawnSync } from "node:child_process";
3+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { test } from "node:test";
7+
8+
const workflow = readFileSync(
9+
new URL("../.github/workflows/migrate-cloudflare-token.yml", import.meta.url),
10+
"utf8",
11+
);
12+
const stepName = "Validate successful deployment after environment copy";
13+
const step = workflow
14+
.split(` - name: ${stepName}\n`)[1]
15+
?.split(" - name: ")[0];
16+
assert.ok(step, `Missing ${stepName} step`);
17+
const script = step.split(" run: |\n")[1]?.replace(/^ {10}/gm, "");
18+
assert.ok(script, `Missing ${stepName} script`);
19+
20+
const requiredSteps = [
21+
"Deploy to Canary Worker",
22+
"Run Smoke Tests on Canary",
23+
"Deploy to Production Worker",
24+
"Verify production deployment ownership",
25+
"Run Smoke Tests on Production",
26+
];
27+
28+
function runValidation(jobs) {
29+
const directory = mkdtempSync(join(tmpdir(), "cloudflare-token-migration-"));
30+
try {
31+
writeFileSync(
32+
join(directory, "gh"),
33+
`#!/usr/bin/env bash
34+
set -euo pipefail
35+
case "$*" in
36+
*"secret list"*) printf '1\\n' ;;
37+
*"environments/production/secrets/CLOUDFLARE_API_TOKEN"*) printf '2026-10-03T14:00:00Z\\n' ;;
38+
*"actions/workflows/deploy.yml"*) printf '123\\n' ;;
39+
*"actions/runs/42/attempts/1/jobs?per_page=100"*) printf '%s\\n' "$MOCK_JOBS" ;;
40+
*"actions/runs/42"*) printf '123\\tworkflow_run\\tmain\\t957245447\\tcompleted\\tsuccess\\t2026-10-03T15:00:00Z\\t1\\n' ;;
41+
*) exit 2 ;;
42+
esac
43+
`,
44+
{ mode: 0o700 },
45+
);
46+
return spawnSync("bash", ["-c", script], {
47+
encoding: "utf8",
48+
env: {
49+
...process.env,
50+
PATH: `${directory}:${process.env.PATH}`,
51+
GH_TOKEN: "dummy",
52+
RUN_TOKEN: "dummy",
53+
TARGET_REPOSITORY: "getsentry/toolkit",
54+
DEPLOYMENT_RUN_ID: "42",
55+
MOCK_JOBS: JSON.stringify(jobs),
56+
},
57+
});
58+
} finally {
59+
rmSync(directory, { recursive: true, force: true });
60+
}
61+
}
62+
63+
test("rejects a successful workflow whose deploy job was skipped", () => {
64+
const result = runValidation({
65+
total_count: 1,
66+
jobs: [
67+
{
68+
name: "Deploy to Cloudflare",
69+
run_attempt: 1,
70+
status: "completed",
71+
conclusion: "skipped",
72+
steps: [],
73+
},
74+
],
75+
});
76+
assert.notEqual(result.status, 0, result.stderr);
77+
});
78+
79+
test("rejects a successful deploy job with skipped production smoke tests", () => {
80+
const result = runValidation({
81+
total_count: 1,
82+
jobs: [
83+
{
84+
name: "Deploy to Cloudflare",
85+
run_attempt: 1,
86+
status: "completed",
87+
conclusion: "success",
88+
steps: requiredSteps.map((name) => ({
89+
name,
90+
status: "completed",
91+
conclusion:
92+
name === "Run Smoke Tests on Production" ? "skipped" : "success",
93+
})),
94+
},
95+
],
96+
});
97+
assert.notEqual(result.status, 0, result.stderr);
98+
});
99+
100+
test("accepts a completed deployment with both smoke tests", () => {
101+
const result = runValidation({
102+
total_count: 1,
103+
jobs: [
104+
{
105+
name: "Deploy to Cloudflare",
106+
run_attempt: 1,
107+
status: "completed",
108+
conclusion: "success",
109+
steps: requiredSteps.map((name) => ({
110+
name,
111+
status: "completed",
112+
conclusion: "success",
113+
})),
114+
},
115+
],
116+
});
117+
assert.equal(result.status, 0, result.stderr);
118+
});
119+
120+
test("rejects missing or partial job results and a different run attempt", () => {
121+
const successfulJob = {
122+
name: "Deploy to Cloudflare",
123+
run_attempt: 1,
124+
status: "completed",
125+
conclusion: "success",
126+
steps: requiredSteps.map((name) => ({
127+
name,
128+
status: "completed",
129+
conclusion: "success",
130+
})),
131+
};
132+
for (const jobs of [
133+
{ total_count: 0, jobs: [] },
134+
{ total_count: 2, jobs: [successfulJob] },
135+
{ total_count: 1, jobs: [{ ...successfulJob, run_attempt: 2 }] },
136+
{
137+
total_count: 1,
138+
jobs: [{ ...successfulJob, steps: successfulJob.steps.slice(0, -1) }],
139+
},
140+
]) {
141+
const result = runValidation(jobs);
142+
assert.notEqual(result.status, 0, result.stderr);
143+
}
144+
});

0 commit comments

Comments
 (0)