From c967bf728bad13518a2df4eecb4cc3d2babafcf0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 00:52:34 -0700 Subject: [PATCH] fix(ci): scope vercel promote to the team that owns the deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production promotion fails on main: Error: Deployment doesn't belong to current team brian-loves-projects-c1a217a2 `vercel build`, `deploy` and `pull` read the team from .vercel/project.json, but `promote` takes a bare deployment URL and cannot — it falls back to the token's default team, which is a personal one, not cacheplane. Both promote calls were introduced without --scope in #963, so neither the Website nor the cockpit redirect service has ever promoted since. refs/deploy/last-promoted still points at 86872580 (#945, 2026-09-01), so everything merged in the last two days is built and verified but never live. Verified both --scope forms against the API: the team slug and the team id in VERCEL_ORG_ID each resolve to cacheplane. Use the existing secret rather than hardcoding a slug, and guard it so an unscoped promote cannot return. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 12 ++++++++++-- scripts/ci-workflow.spec.mjs | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e46d89622..37c4bca5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1077,7 +1077,11 @@ jobs: fi - name: Promote verified Website artifact unchanged if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true' - run: npx vercel promote "${{ steps.deploy_website.outputs.deployment_url }}" --yes --token=${{ secrets.VERCEL_TOKEN }} + # `promote` takes a bare deployment URL, so unlike build/deploy/pull it + # cannot read the team from .vercel/project.json and falls back to the + # token's default team — a personal one here, which fails with + # "Deployment doesn't belong to current team". Scope it explicitly. + run: npx vercel promote "${{ steps.deploy_website.outputs.deployment_url }}" --scope=${{ secrets.VERCEL_ORG_ID }} --yes --token=${{ secrets.VERCEL_TOKEN }} - name: Verify deployed website if: steps.freshness.outputs.stale != 'true' && ((steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true') || steps.affected.outputs.cockpit == 'true') run: npx nx e2e website --skip-nx-cache @@ -1125,7 +1129,11 @@ jobs: fi - name: Promote verified cockpit artifact unchanged if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true' - run: npx vercel promote "${{ steps.deploy_cockpit.outputs.deployment_url }}" --yes --token=${{ secrets.VERCEL_TOKEN }} + # `promote` takes a bare deployment URL, so unlike build/deploy/pull it + # cannot read the team from .vercel/project.json and falls back to the + # token's default team — a personal one here, which fails with + # "Deployment doesn't belong to current team". Scope it explicitly. + run: npx vercel promote "${{ steps.deploy_cockpit.outputs.deployment_url }}" --scope=${{ secrets.VERCEL_ORG_ID }} --yes --token=${{ secrets.VERCEL_TOKEN }} - name: Verify production cockpit redirects if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true' run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode production --retries 20 --retry-delay-ms 5000 diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index d4794125e..ae0270174 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -77,6 +77,21 @@ function readNamedStep(job, name) { } describe('CI workflow', () => { + it('scopes every vercel promote to the team that owns the deployment', async () => { + const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); + const promotes = workflow + .split('\n') + .filter((line) => line.includes('vercel promote')); + + assert.ok(promotes.length >= 2, 'expected Website and cockpit promotions'); + for (const line of promotes) { + // `promote` takes a bare URL and cannot read .vercel/project.json, so + // without --scope it uses the token's default team and fails. + assert.match(line, /--scope=/, `unscoped vercel promote: ${line.trim()}`); + } + }); + + it('runs in a merge queue and reports the required context there', async () => { const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); @@ -368,7 +383,7 @@ describe('CI workflow', () => { assert.match(freshnessStep, /fresh=true.*GITHUB_OUTPUT/); assert.match( promoteStep, - /vercel promote "\$\{\{ steps\.deploy_website\.outputs\.deployment_url \}\}" --yes/ + /vercel promote "\$\{\{ steps\.deploy_website\.outputs\.deployment_url \}\}" --scope=\$\{\{ secrets\.VERCEL_ORG_ID \}\} --yes/ ); assert.match( promoteStep, @@ -519,7 +534,7 @@ describe('CI workflow', () => { assert.match(promotionFreshnessStep, /fresh=true.*GITHUB_OUTPUT/); assert.match( promoteStep, - /vercel promote "\$\{\{ steps\.deploy_cockpit\.outputs\.deployment_url \}\}" --yes/ + /vercel promote "\$\{\{ steps\.deploy_cockpit\.outputs\.deployment_url \}\}" --scope=\$\{\{ secrets\.VERCEL_ORG_ID \}\} --yes/ ); assert.match( promoteStep,