fix(staging): rotate SSH secret handling and env ownership
Staging Backend Quality Gate / validate (pull_request) Successful in 16m32s
Staging Backend Quality Gate / publish (pull_request) Has been skipped

This commit is contained in:
Jesse
2026-08-06 15:34:31 +08:00
parent eccd831ae9
commit cfc7af6b46
16 changed files with 153 additions and 22 deletions
+1 -1
View File
@@ -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.
@@ -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"
+6
View File
@@ -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
+6
View File
@@ -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
+3 -3
View File
@@ -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
+11
View File
@@ -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"