From 0d9b1fd703d17a17774b85c6cd619ac15712d83c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Nov 2026 10:32:46 +0900 Subject: [PATCH] fix(ci): isolate staging Actions settings under STG prefix Map staging Secrets and Variables exclusively from STG names, validate explicit API and SSO settings, and pass them to the staging Worker. Preserve production settings and document the variable registration and Secret migration. Validation: source checks and 113 tests pass. Gitea STG Variables were created and verified; Secret registration remains with the user. --- .gitea/workflows/deploy.yml | 34 ++++++++------- README.md | 48 +++++++++++++++------ scripts/deploy-worker.mjs | 22 ++++++---- scripts/staging-settings.mjs | 36 ++++++++++++++++ tests/staging-deployment.test.js | 72 ++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 36 deletions(-) create mode 100644 scripts/staging-settings.mjs create mode 100644 tests/staging-deployment.test.js diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7b517ca..be702d6 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -10,17 +10,22 @@ jobs: deploy: runs-on: ubuntu-latest env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - ABC_API_KEY: ${{ secrets.ABC_API_KEY }} - ORG_CONTEXT_ENABLED: ${{ vars.STAGING_ORG_CONTEXT_ENABLED }} - ORG_CONTEXT_API_URL: ${{ vars.STAGING_ORG_CONTEXT_API_URL }} - ORG_CONTEXT_SCOPE_MODE: ${{ vars.STAGING_ORG_CONTEXT_SCOPE_MODE }} - ORG_CONTEXT_EXPOSURE: ${{ vars.STAGING_ORG_CONTEXT_EXPOSURE }} - ORG_CONTEXT_TENANT_IDS: ${{ vars.STAGING_ORG_CONTEXT_TENANT_IDS }} - ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS }} - ORG_CONTEXT_KEY_ID: ${{ secrets.STAGING_ORG_CONTEXT_KEY_ID }} - ORG_CONTEXT_KEY_SECRET: ${{ secrets.STAGING_ORG_CONTEXT_KEY_SECRET }} + CLOUDFLARE_API_TOKEN: ${{ secrets.STG_CLOUDFLARE_API_TOKEN }} + SESSION_SECRET: ${{ secrets.STG_SESSION_SECRET }} + ABC_API_KEY: ${{ secrets.STG_ABC_API_KEY }} + AUTH_CLIENT_ID: ${{ vars.STG_AUTH_CLIENT_ID }} + AUTH_ISSUER: ${{ vars.STG_AUTH_ISSUER }} + ABC_API_BASE_URL: ${{ vars.STG_ABC_API_BASE_URL }} + ABC_PROJECT_ID: ${{ vars.STG_ABC_PROJECT_ID }} + ABC_CHANNEL_ID: ${{ vars.STG_ABC_CHANNEL_ID }} + ORG_CONTEXT_ENABLED: ${{ vars.STG_ORG_CONTEXT_ENABLED }} + ORG_CONTEXT_API_URL: ${{ vars.STG_ORG_CONTEXT_API_URL }} + ORG_CONTEXT_SCOPE_MODE: ${{ vars.STG_ORG_CONTEXT_SCOPE_MODE }} + ORG_CONTEXT_EXPOSURE: ${{ vars.STG_ORG_CONTEXT_EXPOSURE }} + ORG_CONTEXT_TENANT_IDS: ${{ vars.STG_ORG_CONTEXT_TENANT_IDS }} + ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STG_ORG_CONTEXT_CACHE_TTL_SECONDS }} + ORG_CONTEXT_KEY_ID: ${{ secrets.STG_ORG_CONTEXT_KEY_ID }} + ORG_CONTEXT_KEY_SECRET: ${{ secrets.STG_ORG_CONTEXT_KEY_SECRET }} steps: - name: Checkout @@ -35,9 +40,10 @@ jobs: shell: bash run: | set -euo pipefail - test -n "$CLOUDFLARE_API_TOKEN" || { echo "CLOUDFLARE_API_TOKEN is missing"; exit 1; } - test -n "$SESSION_SECRET" || { echo "SESSION_SECRET is missing"; exit 1; } - test -n "$ABC_API_KEY" || { echo "ABC_API_KEY is missing"; exit 1; } + test -n "$CLOUDFLARE_API_TOKEN" || { echo "STG_CLOUDFLARE_API_TOKEN is missing"; exit 1; } + test -n "$SESSION_SECRET" || { echo "STG_SESSION_SECRET is missing"; exit 1; } + test -n "$ABC_API_KEY" || { echo "STG_ABC_API_KEY is missing"; exit 1; } + node scripts/deploy-worker.mjs --validate-only - name: Install dependencies run: npm ci --no-fund --no-audit diff --git a/README.md b/README.md index 2dcdc8a..b07b61c 100644 --- a/README.md +++ b/README.md @@ -82,15 +82,37 @@ Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증 ## Gitea Actions 등록값 -운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 아래 기존 등록값은 스테이징 배포용이다. +운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 스테이징 Actions 설정은 `STG_*`, 운영은 `PROD_*` 이름을 사용한다. -저장소 Settings → Actions → Secrets에 아래 기본 Secret 3개를 등록합니다. +저장소 Settings → Actions → Secrets에 아래 스테이징 Secret을 등록합니다. 기존 접두사 없는 이름과 `STAGING_*` 이름은 새 workflow에서 읽지 않습니다. | 이름 | 종류 | 값 | |---|---|---| -| `CLOUDFLARE_API_TOKEN` | Secret | Workers Scripts Edit + Workers R2 Storage Edit 권한의 Cloudflare API Token | -| `SESSION_SECRET` | Secret | `openssl rand -hex 32`로 생성한 세션 서명키 | -| `ABC_API_KEY` | Secret | 프로젝트 전용 ABC UserFeedback API Key | +| `STG_CLOUDFLARE_API_TOKEN` | Secret | 스테이징 Worker·R2 배포용 Cloudflare API Token | +| `STG_SESSION_SECRET` | Secret | 스테이징 세션 서명키. 기존 서명키를 유지하면 기존 로그인 세션도 유지 | +| `STG_ABC_API_KEY` | Secret | 스테이징 ABC project/channel API Key | +| `STG_ORG_CONTEXT_KEY_ID` | Secret | 스테이징 조직 API Key ID | +| `STG_ORG_CONTEXT_KEY_SECRET` | Secret | 스테이징 조직 API Key Secret | + +스테이징 일반 변수는 기존 설정에서 확인한 값으로 아래와 같이 분리한다. 운영 프로젝트·채널 ID를 스테이징에 복사하지 않는다. + +| Variable | 스테이징 값 | +|---|---| +| `STG_AUTH_CLIENT_ID` | `e16898a7-acd2-420a-a224-cdcd89ff66cd` | +| `STG_AUTH_ISSUER` | `https://sso.hmac.kr/oidc` | +| `STG_ABC_API_BASE_URL` | `https://feedback.hmac.kr` | +| `STG_ABC_PROJECT_ID` | `01a0ae3f-fcf6-74b5-bdc4-70d942d6ad72` | +| `STG_ABC_CHANNEL_ID` | `01a0ae40-51c2-7647-a93c-0249b3759777` | +| `STG_ORG_CONTEXT_ENABLED` | `true` | +| `STG_ORG_CONTEXT_API_URL` | `https://sadmin.hmac.kr/api/v1/integrations/org-context` | +| `STG_ORG_CONTEXT_SCOPE_MODE` | `SCOPED` | +| `STG_ORG_CONTEXT_EXPOSURE` | `identifiers` | +| `STG_ORG_CONTEXT_TENANT_IDS` | 기존 스테이징 기준 tenant 7개 JSON 배열 | +| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | `300` | + +workflow가 `STG_`를 제거한 Worker/CLI 환경 이름으로 매핑한다. `scripts/deploy-worker.mjs`는 필수 API·SSO 값이 비었을 때 다른 환경으로 대체하지 않고 중단한다. `AUTH_ISSUER`로 authorization·token·userinfo 주소를 구성한다. `--validate-only`와 `--dry-run`은 Worker·Secret을 변경하지 않는다. + +2026-11-11 확인: 일반 변수 11개를 이 저장소의 Gitea Actions Variables에 등록하고 값 일치를 확인했다. `STG_*` Secret 5개는 사용자가 등록한다. 현재 원격 workflow가 사용하는 기존 이름은 새 Secret 등록 및 workflow 반영 이후 정리한다. 운영 등록 목록은 [production 안내](docs/qa-production-pipeline.md)를 따른다. `CLOUDFLARE_ACCOUNT_ID`는 secret으로 등록할 필요가 없습니다. `wrangler.toml`에 `81fa2d48964d31dd0da9558f9ce601d1`로 설정되어 있습니다. @@ -112,14 +134,14 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런 | Gitea | 위치 | Worker 런타임 | |---|---|---| -| `STAGING_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) | -| `STAGING_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) | -| `STAGING_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` | -| `STAGING_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` | -| `STAGING_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` | -| `STAGING_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` | -| `STAGING_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` | -| `STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) | +| `STG_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) | +| `STG_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) | +| `STG_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` | +| `STG_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` | +| `STG_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` | +| `STG_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` | +| `STG_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` | +| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) | 조회는 `SCOPED`, `identifiers`를 사용합니다. 기준 테넌트는 UUID JSON 배열로 등록하며 UUID v4를 허용합니다. 각 테넌트를 최대 3개씩 동시 조회하고, 완전한 응답만 캐시합니다. 페이지 조회 타임아웃은 요청당 5초입니다. 배포 전 `npm run org:verify`는 요청당 15초를 허용하고, 타임아웃·통신 실패·HTTP 429/502/503/504에 한해 2초 간격으로 최대 3회 시도합니다. 인증 실패·범위 불일치·구성원 누락은 재시도 없이 검증 실패로 처리합니다. diff --git a/scripts/deploy-worker.mjs b/scripts/deploy-worker.mjs index 66f57c8..0585326 100644 --- a/scripts/deploy-worker.mjs +++ b/scripts/deploy-worker.mjs @@ -1,8 +1,9 @@ import { spawn } from 'node:child_process'; -import { deploymentOrgSettings } from './org-context-settings.mjs'; +import { stagingSettings } from './staging-settings.mjs'; const worker = 'baron-qa-gateway-test'; const dryRun = process.argv.includes('--dry-run'); +const validateOnly = process.argv.includes('--validate-only'); function execute(args, input) { return new Promise((resolve, reject) => { const child = spawn('npx', ['wrangler', ...args], { @@ -26,13 +27,16 @@ function execute(args, input) { }); } try { - const { vars, secrets } = deploymentOrgSettings(process.env); - if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets)); - const args = ['deploy', '--name', worker]; - if (dryRun) args.push('--dry-run'); - for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value); - await execute(args); -} catch { - console.error('Worker deployment failed. Check configuration and preceding Wrangler diagnostics.'); + if (process.argv.slice(2).some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown staging deployment option'); + const { vars, secrets } = stagingSettings(process.env); + if (!validateOnly) { + if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets)); + const args = ['deploy', '--config', 'wrangler.toml', '--name', worker]; + if (dryRun) args.push('--dry-run'); + for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value); + await execute(args); + } +} catch (error) { + console.error(error.message || 'Worker deployment failed. Check configuration and preceding Wrangler diagnostics.'); process.exitCode = 1; } diff --git a/scripts/staging-settings.mjs b/scripts/staging-settings.mjs new file mode 100644 index 0000000..d5324db --- /dev/null +++ b/scripts/staging-settings.mjs @@ -0,0 +1,36 @@ +import { deploymentOrgSettings } from './org-context-settings.mjs'; + +function required(env, name) { + const value = String(env[name] || '').trim(); + if (!value) throw new Error('STG_' + name + ' is required for staging'); + return value; +} + +function httpsUrl(value, name, originOnly = false) { + let url; + try { url = new URL(value); } catch { throw new Error('STG_' + name + ' must be an HTTPS URL'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || (originOnly && url.pathname !== '/')) { + throw new Error('STG_' + name + ' must be an HTTPS ' + (originOnly ? 'origin' : 'issuer URL')); + } + return originOnly ? url.origin : url.origin + url.pathname.replace(/\/+$/, ''); +} + +export function stagingSettings(env) { + const organization = deploymentOrgSettings({ ...env, ORG_CONTEXT_ENABLED: required(env, 'ORG_CONTEXT_ENABLED') }); + const issuer = httpsUrl(required(env, 'AUTH_ISSUER'), 'AUTH_ISSUER'); + const vars = { + ...organization.vars, + AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'), + AUTH_AUTHORIZE_URL: issuer + '/oauth2/auth', + AUTH_TOKEN_URL: issuer + '/oauth2/token', + AUTH_USERINFO_URL: issuer + '/userinfo', + ABC_API_BASE_URL: httpsUrl(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL', true), + ABC_PROJECT_ID: required(env, 'ABC_PROJECT_ID'), + ABC_CHANNEL_ID: required(env, 'ABC_CHANNEL_ID') + }; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + for (const name of ['ABC_PROJECT_ID', 'ABC_CHANNEL_ID']) { + if (!uuid.test(vars[name])) throw new Error('STG_' + name + ' must be a UUID'); + } + return { vars, secrets: organization.secrets }; +} diff --git a/tests/staging-deployment.test.js b/tests/staging-deployment.test.js new file mode 100644 index 0000000..6be589d --- /dev/null +++ b/tests/staging-deployment.test.js @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { stagingSettings } from '../scripts/staging-settings.mjs'; + +const fixture = () => ({ + AUTH_CLIENT_ID: 'staging-client', AUTH_ISSUER: 'https://identity.staging.example/oidc/', + ABC_API_BASE_URL: 'https://feedback.staging.example/', + ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001', + ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002', + ORG_CONTEXT_ENABLED: 'false', + PROD_AUTH_CLIENT_ID: 'production-client', PROD_ABC_API_BASE_URL: 'https://production.example' +}); + +test('staging uses its mapped API and SSO settings independently of production values', () => { + const settings = stagingSettings(fixture()); + assert.equal(settings.vars.AUTH_CLIENT_ID, 'staging-client'); + assert.equal(settings.vars.AUTH_AUTHORIZE_URL, 'https://identity.staging.example/oidc/oauth2/auth'); + assert.equal(settings.vars.AUTH_TOKEN_URL, 'https://identity.staging.example/oidc/oauth2/token'); + assert.equal(settings.vars.AUTH_USERINFO_URL, 'https://identity.staging.example/oidc/userinfo'); + assert.equal(settings.vars.ABC_API_BASE_URL, 'https://feedback.staging.example'); + assert.equal(settings.vars.PROD_AUTH_CLIENT_ID, undefined); +}); + +test('missing staging settings cannot silently fall back to a different environment', () => { + for (const name of ['AUTH_CLIENT_ID', 'AUTH_ISSUER', 'ABC_API_BASE_URL', 'ABC_PROJECT_ID', 'ABC_CHANNEL_ID', 'ORG_CONTEXT_ENABLED']) { + const env = fixture(); + delete env[name]; + assert.throws(() => stagingSettings(env), new RegExp('STG_' + name)); + } + assert.throws(() => stagingSettings({ ...fixture(), ABC_CHANNEL_ID: 'not-a-uuid' }), /STG_ABC_CHANNEL_ID/); + assert.throws(() => stagingSettings({ ...fixture(), AUTH_ISSUER: 'http://identity.example' }), /STG_AUTH_ISSUER/); + assert.throws(() => stagingSettings({ ...fixture(), ABC_API_BASE_URL: 'https://api.example/api' }), /STG_ABC_API_BASE_URL/); +}); + +test('staging organization credentials remain separate from public Worker variables', () => { + const settings = stagingSettings({ ...fixture(), ORG_CONTEXT_ENABLED: 'true', + ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]', + ORG_CONTEXT_KEY_ID: 'fixture-key', ORG_CONTEXT_KEY_SECRET: 'fixture-secret' + }); + assert.equal(settings.secrets.ORG_CONTEXT_KEY_SECRET, 'fixture-secret'); + assert.equal(settings.vars.ORG_CONTEXT_KEY_SECRET, undefined); +}); + +test('staging validation never deploys and dry-run pins the staging Worker and config', () => { + const directory = mkdtempSync(join(tmpdir(), 'qa-staging-test-')); + const capture = join(directory, 'commands.jsonl'); + writeFileSync(join(directory, 'npx'), `#!/usr/bin/env node +require('node:fs').appendFileSync(process.env.QA_TEST_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n'); +`, { mode: 0o700 }); + const script = fileURLToPath(new URL('../scripts/deploy-worker.mjs', import.meta.url)); + const env = { ...process.env, ...fixture(), PATH: directory + ':' + process.env.PATH, QA_TEST_CAPTURE: capture }; + try { + const validation = spawnSync(process.execPath, [script, '--validate-only'], { env, encoding: 'utf8' }); + assert.equal(validation.status, 0, validation.stderr); + assert.equal(existsSync(capture), false); + const dryRun = spawnSync(process.execPath, [script, '--dry-run'], { env, encoding: 'utf8' }); + assert.equal(dryRun.status, 0, dryRun.stderr); + const commands = readFileSync(capture, 'utf8').trim().split('\n').map(JSON.parse); + assert.equal(commands.length, 1); + const args = commands[0]; + assert.ok(args.includes('--dry-run')); + assert.ok(args.includes('baron-qa-gateway-test')); + assert.ok(args.includes('wrangler.toml')); + assert.ok(args.includes('ABC_API_BASE_URL:https://feedback.staging.example')); + assert.ok(!args.some(value => value.includes('production.example') || value === 'secret')); + } finally { rmSync(directory, { recursive: true, force: true }); } +});