diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml index d633a2a0..d5378f57 100644 --- a/.gitea/workflows/deploy-staging.yml +++ b/.gitea/workflows/deploy-staging.yml @@ -182,7 +182,7 @@ jobs: - name: Deploy exact image digests under pinned SSH identity env: - SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} DEPLOY_SHA: ${{ steps.revision.outputs.sha }} @@ -196,9 +196,11 @@ jobs: known_hosts_path="$ssh_root/known_hosts" incoming="" install -m 700 -d "$ssh_root" - printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" + test -n "$SSH_PRIVATE_KEY_BASE64" + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode > "$key_path" printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" chmod 600 "$key_path" "$known_hosts_path" + ssh-keygen -y -f "$key_path" >/dev/null ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") remote="$DEPLOY_USER@$DEPLOY_HOST" require_current_staging_head() { diff --git a/.gitea/workflows/migrate-staging-database.yml b/.gitea/workflows/migrate-staging-database.yml index 37de62f9..0b4e750d 100644 --- a/.gitea/workflows/migrate-staging-database.yml +++ b/.gitea/workflows/migrate-staging-database.yml @@ -154,7 +154,7 @@ jobs: - name: Apply digest-pinned migration under host lock env: - SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} DEPLOY_SHA: ${{ steps.revision.outputs.sha }} @@ -166,9 +166,11 @@ jobs: known_hosts_path="$ssh_root/known_hosts" incoming="" install -m 700 -d "$ssh_root" - printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" + test -n "$SSH_PRIVATE_KEY_BASE64" + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode > "$key_path" printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" chmod 600 "$key_path" "$known_hosts_path" + ssh-keygen -y -f "$key_path" >/dev/null ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") remote="$DEPLOY_USER@$DEPLOY_HOST" require_current_staging_head() { diff --git a/.github/workflows/configure-staging-rectification-rollout.yml b/.github/workflows/configure-staging-rectification-rollout.yml index a18fb072..d461c27c 100644 --- a/.github/workflows/configure-staging-rectification-rollout.yml +++ b/.github/workflows/configure-staging-rectification-rollout.yml @@ -73,13 +73,14 @@ jobs: - name: Configure pinned staging SSH env: - SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} run: | set -euo pipefail - test -n "$SSH_PRIVATE_KEY" + test -n "$SSH_PRIVATE_KEY_BASE64" install -d -m 700 ~/.ssh - printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging chmod 600 ~/.ssh/jyotisha-staging + ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 25842b41..3255f1e4 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -154,13 +154,14 @@ jobs: - name: Configure pinned staging SSH env: - SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} run: | set -euo pipefail - test -n "$SSH_PRIVATE_KEY" + test -n "$SSH_PRIVATE_KEY_BASE64" install -m 700 -d ~/.ssh - printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging chmod 600 ~/.ssh/jyotisha-staging + ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts diff --git a/.github/workflows/migrate-staging-database.yml b/.github/workflows/migrate-staging-database.yml index 0072cbff..0636a91a 100644 --- a/.github/workflows/migrate-staging-database.yml +++ b/.github/workflows/migrate-staging-database.yml @@ -116,13 +116,14 @@ jobs: - name: Configure pinned staging SSH env: - SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} run: | set -euo pipefail - test -n "$SSH_PRIVATE_KEY" + test -n "$SSH_PRIVATE_KEY_BASE64" install -m 700 -d ~/.ssh - printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging chmod 600 ~/.ssh/jyotisha-staging + ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts diff --git a/.github/workflows/reset-staging-account.yml b/.github/workflows/reset-staging-account.yml index bfc4af47..d692a1e6 100644 --- a/.github/workflows/reset-staging-account.yml +++ b/.github/workflows/reset-staging-account.yml @@ -62,13 +62,14 @@ jobs: - name: Configure pinned staging SSH env: - SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} run: | set -euo pipefail - test -n "$SSH_PRIVATE_KEY" + test -n "$SSH_PRIVATE_KEY_BASE64" install -d -m 700 ~/.ssh - printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging chmod 600 ~/.ssh/jyotisha-staging + ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts diff --git a/deploy/README.md b/deploy/README.md index 5c5475bb..7521674f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -175,7 +175,7 @@ Staging is isolated from production: | Identity | Better Auth + Resend OTP on the same private PostgreSQL cluster | | Actions control plane | Gitea 1.26.2 (`git.copse.top`) | -Gitea is the primary source repository and Actions control plane. Gitea automatically injects the per-job `${{ secrets.GITEA_TOKEN }}` token; its access is limited by each workflow's `permissions` block and it must not be configured as a repository secret. Configure repository Actions secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `STAGING_SSH_PRIVATE_KEY`, plus variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. The `workflow_run` controller is loaded from the default `main` branch while separately requiring the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. GitHub workflows are upstream/mirror fallback only, not the normal staging release path. +Gitea is the primary source repository and Actions control plane. Gitea automatically injects the per-job `${{ secrets.GITEA_TOKEN }}` token; its access is limited by each workflow's `permissions` block and it must not be configured as a repository secret. Configure repository Actions secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `STAGING_SSH_PRIVATE_KEY`, plus variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. `STAGING_SSH_PRIVATE_KEY` must be the private-key file encoded as one unwrapped base64 line (for example, `base64 < key | tr -d '\n'`), not a multiline PEM/OpenSSH value; staging workflows decode it only into a mode-`0600` temporary file and validate it with `ssh-keygen`. The `workflow_run` controller is loaded from the default `main` branch while separately requiring the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. GitHub workflows are upstream/mirror fallback only, not the normal staging release path. `Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound manifest containing their `sha256` digests. `.gitea/workflows/deploy-staging.yml` consumes that exact successful run, validates its manifest against the full 40-character commit, and deploys digest references rather than trusting the discoverability tags. diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh index 10ef3fd6..ce8fe30e 100755 --- a/deploy/configure-staging-rectification-rollout.sh +++ b/deploy/configure-staging-rectification-rollout.sh @@ -56,10 +56,18 @@ compose_files=( -f deploy/docker-compose.staging.yml ) -[ -f "$env_file" ] || { - echo "staging environment file is missing" >&2 +[ -f "$env_file" ] && [ ! -L "$env_file" ] || { + echo "staging environment file is missing or unsafe" >&2 exit 1 } +EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")" +EXPECTED_STAGING_ENV_OWNER_GID="$(stat -c '%g' "$DEPLOY_PATH" 2>/dev/null || stat -f '%g' "$DEPLOY_PATH")" +[[ "$EXPECTED_STAGING_ENV_OWNER_UID" =~ ^[0-9]+$ && "$EXPECTED_STAGING_ENV_OWNER_GID" =~ ^[0-9]+$ ]] || { + echo "staging deployment owner is invalid" >&2 + exit 1 +} +export EXPECTED_STAGING_ENV_OWNER_UID +bash "$DEPLOY_PATH/deploy/validate-staging-env.sh" "$env_file" current_sha="$(<"$state_directory/deployed-revision")" [ "$current_sha" = "$EXPECTED_DEPLOY_SHA" ] || { echo "deployed staging revision does not match the approved rollout SHA" >&2 @@ -127,6 +135,7 @@ END { for (key in values) if (!(key in written)) print key "=" values[key] } ' "$env_file" >"$temporary" +chown "$EXPECTED_STAGING_ENV_OWNER_UID:$EXPECTED_STAGING_ENV_OWNER_GID" "$temporary" chmod 600 "$temporary" cd "$DEPLOY_PATH" diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh index 44a4e685..7eec5b67 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -120,6 +120,12 @@ bash "$INCOMING_PATH/deploy/sync-staging-tree.sh" \ "$INCOMING_PATH" "$DEPLOY_PATH" cd "$DEPLOY_PATH" +EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")" +[[ "$EXPECTED_STAGING_ENV_OWNER_UID" =~ ^[0-9]+$ ]] || { + echo "staging deployment owner is invalid" >&2 + exit 1 +} +export EXPECTED_STAGING_ENV_OWNER_UID bash deploy/validate-staging-env.sh \ .env.staging staging.jyotisha.chat deploy/Caddyfile.staging bash deploy/validate-staging-database-env.sh .env.staging.database diff --git a/deploy/run-staging-migration.sh b/deploy/run-staging-migration.sh index 1c859f40..60dda620 100755 --- a/deploy/run-staging-migration.sh +++ b/deploy/run-staging-migration.sh @@ -72,6 +72,12 @@ bash "$INCOMING_PATH/deploy/sync-staging-tree.sh" \ "$INCOMING_PATH" "$DEPLOY_PATH" cd "$DEPLOY_PATH" +EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")" +[[ "$EXPECTED_STAGING_ENV_OWNER_UID" =~ ^[0-9]+$ ]] || { + echo "staging deployment owner is invalid" >&2 + exit 1 +} +export EXPECTED_STAGING_ENV_OWNER_UID bash deploy/validate-staging-env.sh \ .env.staging staging.jyotisha.chat deploy/Caddyfile.staging bash deploy/validate-staging-database-env.sh .env.staging.database diff --git a/deploy/validate-staging-database-env.sh b/deploy/validate-staging-database-env.sh index 2949219c..d3f47eed 100755 --- a/deploy/validate-staging-database-env.sh +++ b/deploy/validate-staging-database-env.sh @@ -35,9 +35,9 @@ if OWNER="$(stat -c '%u' "$ENV_FILE" 2>/dev/null)"; then else OWNER="$(stat -f '%u' "$ENV_FILE")" fi - -if [ "$OWNER" != "$(id -u)" ]; then - echo "staging database environment file must be owned by the current user" >&2 +EXPECTED_OWNER_UID="${EXPECTED_STAGING_ENV_OWNER_UID:-$(id -u)}" +if [[ ! "$EXPECTED_OWNER_UID" =~ ^[0-9]+$ ]] || [ "$OWNER" != "$EXPECTED_OWNER_UID" ]; then + echo "staging database environment file has an invalid owner" >&2 exit 1 fi diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index 13bef651..00d26e46 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -24,6 +24,17 @@ if [ "$MODE" != "600" ]; then exit 1 fi +if OWNER="$(stat -c '%u' "$ENV_FILE" 2>/dev/null)"; then + : +else + OWNER="$(stat -f '%u' "$ENV_FILE")" +fi +EXPECTED_OWNER_UID="${EXPECTED_STAGING_ENV_OWNER_UID:-$(id -u)}" +if [[ ! "$EXPECTED_OWNER_UID" =~ ^[0-9]+$ ]] || [ "$OWNER" != "$EXPECTED_OWNER_UID" ]; then + echo "staging environment file has an invalid owner" >&2 + exit 1 +fi + require_selector() { local key="$1" local expected="$2" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 77b4e433..5ccd0113 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2208,3 +2208,19 @@ - 相关记录:BUG-126 - 复发自:无 - 修复版本:本次个人报告 staging 发布提交 + +## BUG-128 | staging deploy 泄漏多行 SSH secret 且 env owner 契约互相冲突 + +- 状态:resolved(local,pending quality-gate rerun) +- 首次发现:2026-08-06 +- 最近更新:2026-08-06 +- 影响面:Gitea/GitHub staging deploy 与 migration workflow、staging SSH 凭据、`.env.staging*` owner、加密备份和发布门禁;production 未受影响。 +- 用户现象:exact-SHA 自动 deploy run `1464` 在应用切换前失败;Gitea job 日志把多行 staging SSH 私钥逐行显示,同时远端数据库 env validator 报 owner 不匹配。公网仍运行旧 SHA。 +- 触发条件:Gitea workflow 将多行 OpenSSH key 直接放入 step env;root 控制脚本验证一个由 `deploy` 持有的 mode-0600 env;此前 root rollout 临时文件又通过 `mv` 把 env owner 改成 root。 +- 根因:Gitea runner 不能可靠遮蔽多行 secret 的每一行;控制面同时混用了“当前脚本用户”和“部署树 owner”作为 env ownership 事实,rollout 覆盖文件时未保留原 owner/gid。 +- 修复:立即停止发布,生成并验证新 staging ED25519 key,精确撤销旧 authorized key,证明旧 key 无法登录,删除本地旧 key,更新 Gitea/GitHub staging secrets,并删除 28 个可能含旧 key 的 Gitea deploy/migration runs。`STAGING_SSH_PRIVATE_KEY` 改为单行 base64;所有 staging workflow 解码到 0600 临时文件并用 `ssh-keygen` 验证。deploy/migration 以部署树 UID 校验两个 env;backup helper 继续以 `deploy` 运行;rollout 临时文件显式保留部署树 owner/gid。 +- 验证:新 key 严格主机校验登录成功,旧 key 登录失败;新 Gitea/GitHub secrets 已更新;泄漏 run `1464` 已删除;focused workflow、owner、shell/YAML 和 governance tests 待本次提交执行,完整 staging gate/deploy 待新 exact SHA。 +- 防复发:禁止 staging workflow 直接注入多行私钥或打印 decoded secret 变量;env owner 必须由部署树身份决定,root 受控脚本不得用 root 临时文件改变持久 env owner。任何凭据日志暴露先轮换/撤销/清理,再修代码和重跑。 +- 相关记录:BUG-124、BUG-127、ERR-092、ERR-093、ERR-094 +- 复发自:无 +- 修复版本:待安全修复 PR 与 staging 验收 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 4cd65db0..92620164 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -139,6 +139,12 @@ Before the personal-report staging migration, the encrypted backup helper correc Prevention: before staging backup/migration, verify both env files are regular, non-symlink files owned by `deploy:deploy` with mode `0600`; restore only that documented owner/mode under the shared mutation lock, without printing or copying file contents. Run `backup-staging-postgres.sh` as `deploy`, never weaken its ancestor checks, never create a parallel root backup tree, and never broaden env permissions. +## ERR-094 | Gitea expanded a multiline staging SSH secret in failed workflow logs | mitigated 2026-08-06 + +A failed exact-SHA staging deploy displayed the multiline staging SSH private key in the job environment block instead of masking each line. Release mutations were stopped immediately. The staging-only key was rotated, the new key was verified before the exposed key was removed from `authorized_keys`, the old key was proven unable to authenticate and deleted locally, Gitea and GitHub staging secrets were replaced, and 28 potentially affected Gitea deploy/migration runs were deleted. The successful quality-gate run and immutable image manifest were retained because they never received the SSH secret. + +Prevention: store `STAGING_SSH_PRIVATE_KEY` only as one unwrapped base64 line; workflows decode it into a mode-`0600` temporary key, validate it with `ssh-keygen`, and delete the temporary directory on every exit. Contract tests must reject direct multiline `SSH_PRIVATE_KEY` injection or `printf` of a decoded secret variable. A leaked staging key must be rotated and revoked before any rerun; production keys remain a separate boundary and were not involved in this incident. + ## Fragment Sweep Command Set ## ERR-086 | Steve Jobs jyotishganit artifacts used non-San-Francisco coordinates | mitigated 2026-07-21 diff --git a/frontend/tests/database-env-validator.test.ts b/frontend/tests/database-env-validator.test.ts index e71ee529..67b8be4a 100644 --- a/frontend/tests/database-env-validator.test.ts +++ b/frontend/tests/database-env-validator.test.ts @@ -192,6 +192,37 @@ test("database env validator rejects symlinks and unsafe modes", () => { } }); +test("database env validator enforces an explicit staging owner uid without printing values", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-owner-")); + const envFile = join(root, ".env.staging.database"); + + try { + writeFileSync(envFile, `${validEnvironment.join("\n")}\n`, { mode: 0o600 }); + chmodSync(envFile, 0o600); + const currentUid = process.getuid?.(); + assert.equal(typeof currentUid, "number"); + + const accepted = spawnSync("bash", [validator, envFile], { + encoding: "utf8", + env: { ...process.env, EXPECTED_STAGING_ENV_OWNER_UID: String(currentUid) }, + }); + assert.equal(accepted.status, 0, accepted.stderr); + + const rejected = spawnSync("bash", [validator, envFile], { + encoding: "utf8", + env: { ...process.env, EXPECTED_STAGING_ENV_OWNER_UID: String((currentUid ?? 0) + 1) }, + }); + assert.notEqual(rejected.status, 0); + assert.match(rejected.stderr, /invalid owner/); + assert.doesNotMatch( + `${rejected.stdout}${rejected.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("database env validator accepts a private valid file without printing values", () => { const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); const envFile = join(root, ".env.staging.database"); diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 40bd2abe..cc6e9845 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -46,6 +46,10 @@ const rolloutWorkflow = new URL( "../../.github/workflows/configure-staging-rectification-rollout.yml", import.meta.url, ); +const resetStagingAccountWorkflow = new URL( + "../../.github/workflows/reset-staging-account.yml", + import.meta.url, +); const deployScript = new URL( "../../deploy/run-staging-deploy.sh", import.meta.url, @@ -259,6 +263,40 @@ test("Gitea staging mutation workflows use the available runner and pinned Node } }); +test("staging SSH secrets are single-line base64 and never injected as multiline private keys", () => { + for (const workflow of [ + read(giteaDeployWorkflow), + read(giteaMigrationWorkflow), + read(deployWorkflow), + read(migrationWorkflow), + read(rolloutWorkflow), + read(resetStagingAccountWorkflow), + ]) { + assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.STAGING_SSH_PRIVATE_KEY \}\}/); + assert.match(workflow, /printf '%s' "\$SSH_PRIVATE_KEY_BASE64" \| base64 --decode/); + assert.match(workflow, /chmod 600 [^\n]*(?:\$key_path|jyotisha-staging)/); + assert.match(workflow, /ssh-keygen -y -f [^\n]+>\/dev\/null/); + assert.doesNotMatch(workflow, /\n\s+SSH_PRIVATE_KEY: \$\{\{ secrets\.STAGING_SSH_PRIVATE_KEY \}\}/); + assert.doesNotMatch(workflow, /printf '%s\\n' "\$SSH_PRIVATE_KEY"/); + } +}); + +test("staging scripts validate deploy-owned env files and rollout preserves their owner", () => { + const deployRunner = read(deployScript); + const migrationRunner = read(migrationScript); + const rolloutRunner = read(rolloutScript); + + for (const runner of [deployRunner, migrationRunner]) { + assert.match(runner, /EXPECTED_STAGING_ENV_OWNER_UID=.*stat[^\n]+"\$DEPLOY_PATH"/); + assert.match(runner, /export EXPECTED_STAGING_ENV_OWNER_UID/); + assert.ok(runner.indexOf("EXPECTED_STAGING_ENV_OWNER_UID=") < runner.indexOf("validate-staging-database-env.sh")); + } + assert.match(rolloutRunner, /EXPECTED_STAGING_ENV_OWNER_UID=.*stat[^\n]+"\$DEPLOY_PATH"/); + assert.match(rolloutRunner, /EXPECTED_STAGING_ENV_OWNER_GID=.*stat[^\n]+"\$DEPLOY_PATH"/); + assert.match(rolloutRunner, /chown "\$EXPECTED_STAGING_ENV_OWNER_UID:\$EXPECTED_STAGING_ENV_OWNER_GID" "\$temporary"/); + assert.ok(rolloutRunner.indexOf("chown \"$EXPECTED_STAGING_ENV_OWNER_UID") < rolloutRunner.indexOf("mv -f -- \"$temporary\" \"$env_file\"")); +}); + test("quality gate builds the Python package with its declared backend dependencies", () => { const workflow = read(qualityWorkflow);