Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8473146773 | |||
| 784ee36cec | |||
| fa8698c95b | |||
| 52b467cbe5 | |||
| 975f5c346d | |||
| 02cc483b7c | |||
| 3431956de1 | |||
| f7a615a5bf | |||
| 0ddbe11eda | |||
| cfc7af6b46 | |||
| eccd831ae9 | |||
| a277efb8f4 | |||
| 8d636fc5ac | |||
| eb4ebf7c73 | |||
| e018dc90a7 |
@@ -262,8 +262,12 @@ jobs:
|
||||
[[ "$web_digest" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||
install -d -m 700 artifacts/staging-images
|
||||
umask 077
|
||||
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' \
|
||||
"$GITEA_SHA" "$api_digest" "$web_digest" \
|
||||
git archive --format=tar --output artifacts/staging-images/controller.tar \
|
||||
"$GITEA_SHA" deploy frontend/scripts/staging-image-manifest.mjs
|
||||
controller_sha256="$(sha256sum artifacts/staging-images/controller.tar | awk '{print $1}')"
|
||||
[[ "$controller_sha256" =~ ^[0-9a-f]{64}$ ]]
|
||||
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\ncontroller_sha256=%s\n' \
|
||||
"$GITEA_SHA" "$api_digest" "$web_digest" "$controller_sha256" \
|
||||
> artifacts/staging-images/manifest.env
|
||||
node frontend/scripts/staging-image-manifest.mjs \
|
||||
artifacts/staging-images/manifest.env "$GITEA_SHA" "$IMAGE_REPOSITORY" >/dev/null
|
||||
@@ -282,7 +286,7 @@ jobs:
|
||||
--workdir "$workdir" \
|
||||
--env HOME=/tmp \
|
||||
--env "INPUT_NAME=staging-image-manifest-$GITEA_SHA-$GITEA_RUN_ATTEMPT" \
|
||||
--env INPUT_PATH=artifacts/staging-images/manifest.env \
|
||||
--env INPUT_PATH=artifacts/staging-images/ \
|
||||
--env INPUT_OVERWRITE=false \
|
||||
--env ACTIONS_RUNTIME_TOKEN \
|
||||
--env ACTIONS_RESULTS_URL \
|
||||
|
||||
@@ -76,30 +76,64 @@ jobs:
|
||||
fi
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging quality gate run found" >&2; exit 1; }
|
||||
|
||||
staging_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
[[ "$staging_head" =~ ^[0-9a-f]{40}$ ]]
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
controller_sha="$(read_ref_sha main)"
|
||||
[[ "$controller_sha" == "$staging_head" ]] || { echo "reviewed main and staging controller heads differ" >&2; exit 1; }
|
||||
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
|
||||
echo "stale staging revision refused; use explicit manual rollback only when intended" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")"
|
||||
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
controller_gate_run_id="$gate_run_id"
|
||||
if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then
|
||||
controller_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$controller_sha&branch=staging&event=push&status=success&limit=100")"
|
||||
controller_run="$(jq -cer --arg sha "$controller_sha" '
|
||||
[.workflow_runs[] | select(
|
||||
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
|
||||
.head_sha == $sha and .head_branch == "staging" and
|
||||
.event == "push" and .conclusion == "success"
|
||||
)] | sort_by(.id) | reverse | first
|
||||
' <<<"$controller_runs")"
|
||||
controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")"
|
||||
fi
|
||||
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]]
|
||||
{
|
||||
echo "sha=$REQUESTED_SHA"
|
||||
echo "gate_run_id=$gate_run_id"
|
||||
echo "controller_sha=$controller_sha"
|
||||
echo "controller_gate_run_id=$controller_gate_run_id"
|
||||
echo "allow_rollback=$allow_rollback"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout trusted main controller
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
git fetch --no-tags origin main "$DEPLOY_SHA"
|
||||
git checkout --detach --force origin/main
|
||||
git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { echo "staging revision is not in trusted main history" >&2; exit 1; }
|
||||
|
||||
- name: Prepare pinned Node tooling
|
||||
env:
|
||||
NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c
|
||||
@@ -140,49 +174,108 @@ jobs:
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Download gate-produced image manifest
|
||||
- name: Download target and controller gate artifacts
|
||||
env:
|
||||
GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }}
|
||||
TARGET_GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }}
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
CONTROLLER_GATE_RUN_ID: ${{ steps.revision.outputs.controller_gate_run_id }}
|
||||
CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifact_prefix="staging-image-manifest-$DEPLOY_SHA-"
|
||||
artifacts="$(curl --fail --silent --show-error \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$GATE_RUN_ID/artifacts?limit=100")"
|
||||
selected_artifact="$(jq -cer --arg prefix "$artifact_prefix" '
|
||||
[(.artifacts // [])[]
|
||||
| select(.expired == false and (.name | startswith($prefix)))
|
||||
| . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)}
|
||||
| select(.attempt != null and .attempt >= 1)
|
||||
] | sort_by(.attempt, .id) | reverse | first
|
||||
' <<<"$artifacts")"
|
||||
artifact_name="$(jq -er '.name' <<<"$selected_artifact")"
|
||||
artifact_id="$(jq -er '.id' <<<"$selected_artifact")"
|
||||
artifact_attempt="${artifact_name#"$artifact_prefix"}"
|
||||
[[ "$artifact_name" == "$artifact_prefix"* ]]
|
||||
[[ "$artifact_attempt" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "$artifact_id" =~ ^[0-9]+$ ]]
|
||||
install -d -m 700 artifacts/staging-image
|
||||
curl --fail --silent --show-error --location \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \
|
||||
--output "${RUNNER_TEMP}/staging-image-manifest.zip"
|
||||
unzip -q "${RUNNER_TEMP}/staging-image-manifest.zip" -d artifacts/staging-image
|
||||
[[ -f artifacts/staging-image/manifest.env ]]
|
||||
download_bundle() {
|
||||
local run_id="$1" sha="$2" destination="$3" zip_path="$4"
|
||||
local prefix artifacts selected name id attempt
|
||||
prefix="staging-image-manifest-$sha-"
|
||||
artifacts="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$run_id/artifacts?limit=100")"
|
||||
selected="$(jq -cer --arg prefix "$prefix" '
|
||||
[(.artifacts // [])[]
|
||||
| select(.expired == false and (.name | startswith($prefix)))
|
||||
| . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)}
|
||||
| select(.attempt != null and .attempt >= 1)
|
||||
] | sort_by(.attempt, .id) | reverse | first
|
||||
' <<<"$artifacts")"
|
||||
name="$(jq -er '.name' <<<"$selected")"
|
||||
id="$(jq -er '.id' <<<"$selected")"
|
||||
attempt="${name#"$prefix"}"
|
||||
[[ "$name" == "$prefix"* && "$attempt" =~ ^[1-9][0-9]*$ && "$id" =~ ^[0-9]+$ ]]
|
||||
install -d -m 700 "$destination"
|
||||
curl --fail --silent --show-error --location --connect-timeout 15 --max-time 120 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$id/zip" \
|
||||
--output "$zip_path"
|
||||
python3 - "$zip_path" "$destination" <<'PY'
|
||||
import pathlib, stat, sys, zipfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
destination = pathlib.Path(sys.argv[2])
|
||||
allowed = {"manifest.env", "controller.tar"}
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
entries = bundle.infolist()
|
||||
names = [entry.filename for entry in entries]
|
||||
if len(names) != len(set(names)) or not names or not set(names).issubset(allowed):
|
||||
raise SystemExit("invalid staging artifact bundle")
|
||||
if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024:
|
||||
raise SystemExit("staging artifact bundle is too large")
|
||||
for entry in entries:
|
||||
path = pathlib.PurePosixPath(entry.filename)
|
||||
mode = entry.external_attr >> 16
|
||||
if path.is_absolute() or ".." in path.parts or path.name != entry.filename:
|
||||
raise SystemExit("unsafe staging artifact path")
|
||||
if mode and not stat.S_ISREG(mode):
|
||||
raise SystemExit("unsafe staging artifact type")
|
||||
target = destination / entry.filename
|
||||
with bundle.open(entry) as source, target.open("xb") as output:
|
||||
output.write(source.read())
|
||||
PY
|
||||
[[ -f "$destination/manifest.env" ]]
|
||||
}
|
||||
rm -rf artifacts/staging-image artifacts/controller
|
||||
download_bundle "$TARGET_GATE_RUN_ID" "$DEPLOY_SHA" artifacts/staging-image "${RUNNER_TEMP}/staging-target.zip"
|
||||
download_bundle "$CONTROLLER_GATE_RUN_ID" "$CONTROLLER_SHA" artifacts/controller "${RUNNER_TEMP}/staging-controller.zip"
|
||||
|
||||
- name: Validate immutable image manifest
|
||||
- name: Validate gate-attested controller and immutable image manifest
|
||||
id: images
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node frontend/scripts/staging-image-manifest.mjs \
|
||||
controller_manifest=artifacts/controller/manifest.env
|
||||
controller_tar=artifacts/controller/controller.tar
|
||||
[[ -f "$controller_tar" ]]
|
||||
[[ "$(wc -l < "$controller_manifest" | tr -d ' ')" == 4 ]]
|
||||
manifest_controller_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$controller_manifest")"
|
||||
expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$controller_manifest")"
|
||||
[[ "$manifest_controller_sha" == "$CONTROLLER_SHA" ]]
|
||||
[[ "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]]
|
||||
printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status
|
||||
python3 - "$controller_tar" <<'PY'
|
||||
import pathlib, sys, tarfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
required = {"deploy/run-staging-deploy.sh", "frontend/scripts/staging-image-manifest.mjs"}
|
||||
with tarfile.open(archive, "r:") as bundle:
|
||||
members = bundle.getmembers()
|
||||
names = [member.name for member in members]
|
||||
if len(names) != len(set(names)) or not required.issubset(names):
|
||||
raise SystemExit("invalid staging controller bundle")
|
||||
if sum(member.size for member in members) > 2 * 1024 * 1024:
|
||||
raise SystemExit("staging controller bundle is too large")
|
||||
for member in members:
|
||||
path = pathlib.PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()):
|
||||
raise SystemExit("unsafe staging controller bundle")
|
||||
PY
|
||||
install -d -m 700 artifacts/controller/extracted
|
||||
tar -xf "$controller_tar" -C artifacts/controller/extracted
|
||||
node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \
|
||||
"$controller_manifest" "$CONTROLLER_SHA" "$IMAGE_REPOSITORY" >/dev/null
|
||||
node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \
|
||||
artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- 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,14 +289,21 @@ 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() {
|
||||
[[ "$ALLOW_ROLLBACK" == true ]] && return
|
||||
current_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
current_head="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/staging" |
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/staging") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; }
|
||||
}
|
||||
cleanup() {
|
||||
@@ -216,15 +316,26 @@ jobs:
|
||||
incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")"
|
||||
[[ "$incoming" == /tmp/jyotisha-staging.* ]]
|
||||
ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'"
|
||||
tar -cf "${RUNNER_TEMP}/deploy.tar" deploy
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" "${RUNNER_TEMP}/deploy.tar" "$remote:$incoming/deploy.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'"
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/controller/controller.tar "$remote:$incoming/controller.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'"
|
||||
previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")"
|
||||
[[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
forward_verified=false
|
||||
if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" && "$ALLOW_ROLLBACK" != true ]]; then
|
||||
git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"
|
||||
git merge-base --is-ancestor "$previous_sha" "$DEPLOY_SHA" || { echo "automatic staging rollback or divergent deploy refused" >&2; exit 1; }
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")"
|
||||
jq -e --arg base "$previous_sha" --arg head "$DEPLOY_SHA" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "automatic staging rollback or divergent deploy refused" >&2; exit 1; }
|
||||
forward_verified=true
|
||||
fi
|
||||
require_current_staging_head
|
||||
|
||||
@@ -41,8 +41,21 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
|
||||
staging_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
main_head="$(read_ref_sha main)"
|
||||
[[ "$staging_head" == "$DEPLOY_SHA" ]] || { echo "migration requires current staging head" >&2; exit 1; }
|
||||
[[ "$main_head" == "$DEPLOY_SHA" ]] || { echo "staging migration revision must equal reviewed main head" >&2; exit 1; }
|
||||
runs="$(curl --fail --silent --show-error \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
|
||||
@@ -60,18 +73,6 @@ jobs:
|
||||
echo "gate_run_id=$gate_run_id"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout trusted main controller
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
git fetch --no-tags origin main "$DEPLOY_SHA"
|
||||
git checkout --detach --force origin/main
|
||||
git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { echo "staging revision is not in trusted main history" >&2; exit 1; }
|
||||
|
||||
- name: Prepare pinned Node tooling
|
||||
env:
|
||||
NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c
|
||||
@@ -140,21 +141,69 @@ jobs:
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \
|
||||
--output "${RUNNER_TEMP}/staging-image-manifest.zip"
|
||||
unzip -q "${RUNNER_TEMP}/staging-image-manifest.zip" -d artifacts/staging-image
|
||||
python3 - "${RUNNER_TEMP}/staging-image-manifest.zip" artifacts/staging-image <<'PY'
|
||||
import pathlib, stat, sys, zipfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
destination = pathlib.Path(sys.argv[2])
|
||||
allowed = {"manifest.env", "controller.tar"}
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
entries = bundle.infolist()
|
||||
names = [entry.filename for entry in entries]
|
||||
if len(names) != len(set(names)) or set(names) != allowed:
|
||||
raise SystemExit("invalid staging artifact bundle")
|
||||
if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024:
|
||||
raise SystemExit("staging artifact bundle is too large")
|
||||
for entry in entries:
|
||||
path = pathlib.PurePosixPath(entry.filename)
|
||||
mode = entry.external_attr >> 16
|
||||
if path.is_absolute() or ".." in path.parts or path.name != entry.filename:
|
||||
raise SystemExit("unsafe staging artifact path")
|
||||
if mode and not stat.S_ISREG(mode):
|
||||
raise SystemExit("unsafe staging artifact type")
|
||||
target = destination / entry.filename
|
||||
with bundle.open(entry) as source, target.open("xb") as output:
|
||||
output.write(source.read())
|
||||
PY
|
||||
[[ -f artifacts/staging-image/manifest.env ]]
|
||||
[[ -f artifacts/staging-image/controller.tar ]]
|
||||
|
||||
- name: Validate digest-pinned migration image
|
||||
- name: Validate gate-attested controller and digest-pinned migration image
|
||||
id: image
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node frontend/scripts/staging-image-manifest.mjs \
|
||||
artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
|
||||
manifest=artifacts/staging-image/manifest.env
|
||||
controller_tar=artifacts/staging-image/controller.tar
|
||||
[[ "$(wc -l < "$manifest" | tr -d ' ')" == 4 ]]
|
||||
manifest_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$manifest")"
|
||||
expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$manifest")"
|
||||
[[ "$manifest_sha" == "$DEPLOY_SHA" && "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]]
|
||||
printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status
|
||||
python3 - "$controller_tar" <<'PY'
|
||||
import pathlib, sys, tarfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
required = {"deploy/run-staging-migration.sh", "frontend/scripts/staging-image-manifest.mjs"}
|
||||
with tarfile.open(archive, "r:") as bundle:
|
||||
members = bundle.getmembers()
|
||||
names = [member.name for member in members]
|
||||
if len(names) != len(set(names)) or not required.issubset(names):
|
||||
raise SystemExit("invalid staging controller bundle")
|
||||
if sum(member.size for member in members) > 2 * 1024 * 1024:
|
||||
raise SystemExit("staging controller bundle is too large")
|
||||
for member in members:
|
||||
path = pathlib.PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()):
|
||||
raise SystemExit("unsafe staging controller bundle")
|
||||
PY
|
||||
install -d -m 700 artifacts/staging-image/extracted
|
||||
tar -xf "$controller_tar" -C artifacts/staging-image/extracted
|
||||
node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \
|
||||
"$manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- 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,13 +215,20 @@ 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() {
|
||||
current_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
current_head="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/staging" |
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/staging") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during migration; refusing stale mutation" >&2; exit 1; }
|
||||
}
|
||||
cleanup() {
|
||||
@@ -185,15 +241,26 @@ jobs:
|
||||
incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")"
|
||||
[[ "$incoming" == /tmp/jyotisha-staging.* ]]
|
||||
ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'"
|
||||
tar -cf "${RUNNER_TEMP}/deploy.tar" deploy
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" "${RUNNER_TEMP}/deploy.tar" "$remote:$incoming/deploy.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'"
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/staging-image/controller.tar "$remote:$incoming/controller.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'"
|
||||
previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")"
|
||||
[[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
forward_verified=false
|
||||
if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" ]]; then
|
||||
git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"
|
||||
git merge-base --is-ancestor "$previous_sha" "$DEPLOY_SHA" || { echo "migration rollback or divergence refused" >&2; exit 1; }
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")"
|
||||
jq -e --arg base "$previous_sha" --arg head "$DEPLOY_SHA" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "migration rollback or divergence refused" >&2; exit 1; }
|
||||
forward_verified=true
|
||||
fi
|
||||
require_current_staging_head
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2208,3 +2208,35 @@
|
||||
- 相关记录:BUG-126
|
||||
- 复发自:无
|
||||
- 修复版本:本次个人报告 staging 发布提交
|
||||
|
||||
## BUG-128 | staging deploy 泄漏多行 SSH secret 且 env owner 契约互相冲突
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现: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` 已删除;本地 workflow contracts 31/31、personal-report 142/142、owner regression、shell/YAML、TypeScript、ESLint、governance 和 pre-work 通过;Gitea quality gate run `1465` 在完整 Docker/PostgreSQL runner 中成功。新 exact-SHA migration/deploy 仍按发布流程单独验收。
|
||||
- 防复发:禁止 staging workflow 直接注入多行私钥或打印 decoded secret 变量;env owner 必须由部署树身份决定,root 受控脚本不得用 root 临时文件改变持久 env owner。任何凭据日志暴露先轮换/撤销/清理,再修代码和重跑。
|
||||
- 相关记录:BUG-124、BUG-127、ERR-092、ERR-093、ERR-094
|
||||
- 复发自:无
|
||||
- 修复版本:`f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`;staging migration/deploy 与安全验收完成
|
||||
|
||||
## BUG-129 | staging trusted-main checkout 无界 fetch 导致自动部署长期占用 mutation queue
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现:2026-08-06
|
||||
- 最近更新:2026-08-06
|
||||
- 影响面:Gitea staging deploy/migration 控制器的 trusted-main checkout;production 与 staging 应用数据面未受影响。
|
||||
- 用户现象:exact-SHA quality gate run `1473` 成功后,自动 deploy run `1474` 在 `git fetch --no-tags origin main "$DEPLOY_SHA"` 长时间没有日志进展;fetch 后续自行恢复,run 最终于 18 分钟成功部署 `02cc483b7c303e6cc0f26fb31462c50adb007f12`。第一轮 bounded-retry 修复合入后,run `1480` 的 3 次 120 秒 fetch 全部在服务端压缩 16,093 个对象时耗尽并 fail closed;SSH/远端 mutation 未开始,公网/state 继续健康运行 `02cc483b7c303e6cc0f26fb31462c50adb007f12`。
|
||||
- 触发条件:空仓库命令 `git fetch --no-tags origin main "$DEPLOY_SHA"` 同时请求分支和目标 SHA,导致 Gitea 为每次尝试枚举/压缩完整历史对象;runner 与服务端之间的传输无法在 120 秒内完成。
|
||||
- 根因:原控制器既没有命令级 timeout,也错误地为正常前向发布抓取 full-history dual ref。第一轮修复只增加 bounded retry,解决了无界占用,但旧回归测试只断言 timeout/attempt/ancestry,未限制传输对象范围,因而未拦住连续三次重新打包完整历史。
|
||||
- 修复:不再让 mutation runner 做任何 Git object fetch。成功 staging gate 从其已验证的 exact SHA 生成仅含 tracked `deploy/` 与严格 manifest validator 的 `controller.tar`,将 tar SHA-256 写入四字段 manifest,并与 immutable image digests 一起上传。deploy/migration 从 exact successful gate artifact 下载 bundle,强制校验 controller SHA、tar hash、路径、重复项、类型和 2 MiB 上限后才解包;正常发布使用当前 `main == staging` controller,手工旧版 rollback 也不得执行旧 controller。refs 与 forward/rollback 关系通过有界 Gitea API 和完整 commit-DAG 路径证明,字段缺失、分页不完整、头不一致或证据冲突均 fail closed。
|
||||
- 验证:第一轮 bounded retry 的本地 workflow contracts 31/31、PR gates `1475/1477` 与 staging gate `1479` 成功;run `1480` 证明 3 次 120 秒耗尽后无半部署。bundle 修复本地 manifest/workflow contracts 34/34、三份 YAML、shell、真实 25-entry/122,880-byte controller tar hash/安全检查、mutation Git-object-op=0、mandatory pre-work 和 diff 检查通过。完整 PR gate、staging gate 和 exact-SHA deploy 待完成;完成前不得再次标记 resolved。
|
||||
- 防复发:所有 release-controller 网络调用必须有命令级上限和失败闭合;mutation workflow 禁止 `git fetch/ls-remote/cat-file/merge-base/checkout/init`。控制器必须来自 exact successful gate 的 hash-bound artifact,正常与 rollback 均使用当前 reviewed controller;测试必须覆盖 artifact identity、tar safety、commit-DAG proof 和旧 Git object 路径为零。
|
||||
- 相关记录:BUG-128、ERR-094、ERR-095
|
||||
- 复发自:BUG-129 第一轮修复未覆盖对象范围
|
||||
- 修复版本:待 gate-attested controller bundle 与 staging 验收
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Personal Report Staging Acceptance — 2026-08-06
|
||||
|
||||
This record contains only release identities, aggregate operational evidence, schema metadata, stable status codes, and synthetic transaction counts. It contains no user identity, birth data, report body, OTP, password, cookie, JWT, model key, database credential, SSH private key, or raw environment file.
|
||||
|
||||
## Release identity
|
||||
|
||||
- Pre-change application baseline: `49da8f916960030d5760d8dedf4e77820732a527`
|
||||
- Read-only upstream Skill source commit: `unknown` (source directory has no usable Git metadata)
|
||||
- Read-only upstream tree SHA-256: `9034e1967032d09c7fbae83fc2205f7e75e8ad482c5f9eba1bf309fe30aef5bb`
|
||||
- Personal-report implementation merge: `e018dc90a73d49596563b5ae2b5fc203402cc86a`
|
||||
- Security/control-plane merge: `f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`
|
||||
- Staging migration and application-under-test SHA: `f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`
|
||||
- Docs-only acceptance attestation deployment SHA: `02cc483b7c303e6cc0f26fb31462c50adb007f12`
|
||||
- Application rollback target: `49da8f916960030d5760d8dedf4e77820732a527`, subject to retained successful gate artifacts. The additive database migration remains in place after application rollback.
|
||||
|
||||
At deployment verification, Gitea `main`, Gitea `staging`, `/opt/jyotisha-staging/.state/deployed-revision`, and public `/api/health` all reported the same full SHA.
|
||||
|
||||
## Automated quality evidence
|
||||
|
||||
### Local
|
||||
|
||||
- Mandatory `scripts/pre_work_check.py`: pass, including fragment scan, external-engine adapter diagnostic, remote visibility, and focused governance tests.
|
||||
- Personal-report frontend suite: 142/142.
|
||||
- Core Python report/import/orchestrator matrix: 87/87.
|
||||
- TypeScript: pass.
|
||||
- Next production build: pass; NFT whole-project warning count 0.
|
||||
- ESLint: 0 errors; 4 unrelated pre-existing warnings.
|
||||
- Staging workflow/security contracts after credential hardening: 31/31.
|
||||
- JSON Schema/Zod/Python contract, dual migration, owner service, API, entitlement, grounded generation, reader, D1 SVG, and browser print contracts: pass.
|
||||
|
||||
The complete frontend suite could not be made fully executable on the local macOS host because Docker CLI and the bare `python` command were absent. This was not reported as green locally.
|
||||
|
||||
### Gitea complete runner
|
||||
|
||||
- PR quality gate `1459`: success; Python quick gate 291 passed / 1 skipped and frontend 1409/1409, including real Docker/PostgreSQL migration fixture.
|
||||
- Final personal-report PR quality gate `1461`: success.
|
||||
- Security-control-plane PR quality gates `1465` and `1467`: success, including Docker/PostgreSQL fixtures.
|
||||
- Final `staging` push quality gate `1469`: success for full SHA `f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`; immutable API/web manifest published.
|
||||
- Automatic deploy check `1470`: stopped safely with exit 3 because the report migration was pending. Its logs showed the base64 SSH secret as masked and no private-key header/material.
|
||||
- Manual migration `1471`: success; `20260806000000_personal_reports.sql` applied and present once in the migration ledger.
|
||||
- Manual deploy `1472`: success with the same exact application-under-test SHA and `allow_rollback=false`.
|
||||
- Docs-only attestation gate `1473`: success for `02cc483b7c303e6cc0f26fb31462c50adb007f12`; immutable manifest published.
|
||||
- Docs-only attestation deploy `1474`: success. The trusted-main fetch paused for an extended period before recovering; no SSH/staging mutation occurred during the pause. Public health and host state then moved to the exact attestation SHA with zero container restarts. See `BUG-129` / `ERR-095` for the bounded-fetch follow-up.
|
||||
|
||||
## Security incident and containment
|
||||
|
||||
Before the final release, an earlier failed staging-only deploy exposed the then-current multiline staging SSH key in Gitea logs. The application had not switched and production was not involved.
|
||||
|
||||
Containment completed before any rerun:
|
||||
|
||||
- generated and verified a new staging ED25519 key;
|
||||
- revoked the exposed authorized key and proved it no longer authenticated;
|
||||
- deleted the old local key;
|
||||
- replaced the Gitea and GitHub staging secrets;
|
||||
- deleted 28 potentially affected Gitea deploy/migration runs;
|
||||
- retained the quality-gate run and immutable image artifact, which never received the SSH secret;
|
||||
- changed the staging secret contract to one-line base64, decoded only into a mode-0600 temporary key and validated by `ssh-keygen`;
|
||||
- fixed `.env.staging*` ownership validation to use the deployment-tree owner and fixed rollout replacement to preserve owner/gid.
|
||||
|
||||
The staging host key had also changed before inspection. Strict SSH was paused until the observed ED25519 key exactly matched the independently administered Gitea `STAGING_KNOWN_HOSTS` value. Strict checking was never disabled. See `ERR-092`, `ERR-093`, `ERR-094`, and `BUG-128`.
|
||||
|
||||
## Migration and authorization evidence
|
||||
|
||||
Post-deploy schema inspection reported:
|
||||
|
||||
- `public.personal_reports` exists;
|
||||
- RLS enabled;
|
||||
- policies: owner SELECT and owner DELETE;
|
||||
- `authenticated`: SELECT, DELETE only;
|
||||
- `service_role`: SELECT, INSERT, UPDATE, DELETE;
|
||||
- personal-report migration ledger count: 1.
|
||||
|
||||
A two-owner synthetic RLS test ran entirely inside one PostgreSQL transaction and then rolled back:
|
||||
|
||||
- owner SELECT count: 1;
|
||||
- cross-owner SELECT count: 0;
|
||||
- cross-owner DELETE count: 0;
|
||||
- owner DELETE count: 1;
|
||||
- persisted synthetic users after rollback: 0;
|
||||
- persisted synthetic reports after rollback: 0.
|
||||
|
||||
No report document or birth fact was needed or persisted for this check.
|
||||
|
||||
## Health and unauthenticated smoke
|
||||
|
||||
- Public `/api/health`: `ok`; deployment SHA exact match.
|
||||
- `/login`: 200.
|
||||
- Logged-out `/api/account`: 401.
|
||||
- Logged-out report GET and POST: 401.
|
||||
- Logged-out report reader route: reachable; its data API remains authenticated.
|
||||
- Internal Python `/api/health`: 200, `status=ok`, `swisseph_available=true`.
|
||||
- API, web, PostgreSQL, worker, and Caddy containers running; API/web/PostgreSQL health checks healthy.
|
||||
- API/web/Caddy recent fatal/report-guard error-signature count: 0.
|
||||
- All five staging containers restart count: 0.
|
||||
- Server web container: no Chromium, Chrome, Playwright, or Puppeteer executable/process.
|
||||
- Staging report selectors: one canonical enabled selector and one canonical daily-limit selector; env files remain `deploy:deploy` mode 0600.
|
||||
- Legacy-compatible model API key, HTTPS base URL, model ID, Python API URL, report feature flag, and report limit are present in the web runtime. Values were not printed.
|
||||
|
||||
## Idle resource baseline
|
||||
|
||||
One post-deploy no-load sample (not a concurrency claim):
|
||||
|
||||
| Service | CPU | Memory |
|
||||
| --- | ---: | ---: |
|
||||
| API | 0.02% | 48.54 MiB |
|
||||
| Caddy | 0.35% | 36.15 MiB |
|
||||
| PostgreSQL | 0.16% | 44.67 MiB |
|
||||
| Rectification worker | 0.30% | 194.8 MiB |
|
||||
| Web | 1.69% | 130.3 MiB |
|
||||
|
||||
This is an idle snapshot only. It does not satisfy the planned single-user/two-user report-generation performance measurement.
|
||||
|
||||
## Backups
|
||||
|
||||
- Pre-migration encrypted staging backup: success.
|
||||
- Post-deploy encrypted staging backup: success.
|
||||
- Retention after post-deploy backup: 3 archives.
|
||||
- Backup directory mode: 0700.
|
||||
- Latest archive mode: 0600; non-empty (717,008 bytes).
|
||||
- No secret was passed in argv or printed.
|
||||
|
||||
## Blocked / user handoff
|
||||
|
||||
The following are intentionally **not** marked complete because no authorized synthetic browser account credentials were configured in Gitea/GitHub, and existing real users were not borrowed:
|
||||
|
||||
1. End-to-end authenticated model report generation from an accepted/confirmed synthetic profile.
|
||||
2. Browser verification of summary → themes → evidence order using a real ready document.
|
||||
3. Browser cross-owner report URL check (database RLS isolation was verified transactionally).
|
||||
4. Desktop Chrome Print → Save as PDF.
|
||||
5. macOS Safari Print → Save as PDF.
|
||||
6. iPhone Safari Share/Print and WeChat guidance.
|
||||
7. 20–40-page pagination, Chinese font, SVG sharpness, and table clipping checks.
|
||||
8. Serialized ready-document byte size and generated PDF byte size from a real report.
|
||||
9. Single-user generation CPU/memory/duration and two-user concurrent-generation resource evidence.
|
||||
|
||||
These require the user/browser handoff in `docs/operations/personal-report-staging.md`. Failure, unavailable model, blocked evidence, or generation timeouts must be recorded as observed; they must not be converted into a success claim.
|
||||
|
||||
## Production boundary
|
||||
|
||||
No production deployment, production migration, production secret rotation, production database operation, domain change, or Supabase production change was performed.
|
||||
@@ -127,6 +127,30 @@ Four migration pairs reused the same timestamp prefix, while Supabase records th
|
||||
|
||||
Prevention: `tests/test_supabase_migration_versions.py` requires every migration prefix to be unique. Preserve already-recorded versions, move skipped SQL into later uniquely numbered repair migrations, run `supabase db push --linked --dry-run`, and verify the remote migration ledger plus live schema before deploying dependent application code.
|
||||
|
||||
## ERR-092 | Staging SSH host key changed before release inspection | mitigated 2026-08-06
|
||||
|
||||
A direct strict SSH inspection of `118.26.111.127` stopped with `REMOTE HOST IDENTIFICATION HAS CHANGED`: the local file still contained an older ED25519/RSA/ECDSA set, while the server presented a new ED25519 fingerprint. The release was paused; strict host checking was never disabled. The current ED25519 key was accepted only after its complete key material and fingerprint matched the repository Actions variable `STAGING_KNOWN_HOSTS` exactly. The local `known_hosts` file was backed up, records for only this IP were removed, and only the trusted ED25519 record was installed. The staging deployment tree is root-owned and the SSH user cannot `cd` into it directly; inspections must use the existing constrained `sudo -n` workflow boundary rather than changing directory ownership or permissions.
|
||||
|
||||
Prevention: on any staging host-key warning, stop before SSH/deploy; compare the observed key against an independently administered trusted source such as `STAGING_KNOWN_HOSTS`, and require exact key-material equality. Never trust `ssh-keyscan` alone, never use `StrictHostKeyChecking=no`, and never overwrite unrelated known-host entries. Preserve a local backup and record the trusted fingerprint. Do not `chmod` or `chown` `/opt/jyotisha-staging` to make ad-hoc inspection easier.
|
||||
|
||||
## ERR-093 | Staging env ownership drift blocked the reviewed backup helper | mitigated 2026-08-06
|
||||
|
||||
Before the personal-report staging migration, the encrypted backup helper correctly refused to write into the private `deploy`-owned backup tree when invoked as root. Read-only inspection then showed both `.env.staging` and `.env.staging.database` had drifted to `root:root 0600`, although the deployment tree, backup directory, state directory, and Docker-capable deployment account are owned by `deploy`; the operations runbooks explicitly require the env files to be owned by the deployment user. This made the correct `deploy` execution unable to read its database env while root could not pass the helper's private-directory ownership boundary.
|
||||
|
||||
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.
|
||||
|
||||
## ERR-095 | Gitea trusted-main full-history fetch stalls or exhausts every bounded attempt | investigating 2026-08-06
|
||||
|
||||
After exact-SHA staging gate `1473` succeeded, automatic deploy `1474` stopped making log progress for an extended period in the empty-repository `git fetch --no-tags origin main "$DEPLOY_SHA"` step before any SSH or staging mutation. The fetch later recovered and the 18-minute run successfully deployed the exact SHA. The first mitigation added three 120-second attempts, but run `1480` proved every attempt still asked Gitea to enumerate/compress 16,093 full-history objects and then timed out. It failed closed before SSH; public and state SHAs remained on the prior healthy release. The first fix bounded queue occupation but did not reduce the transfer, and its regression test did not reject the full-history dual-ref form.
|
||||
|
||||
Prevention: Gitea mutation workflows must perform no Git object operations. A successful staging gate packages its already-verified exact-SHA `deploy/` controller plus manifest validator into `controller.tar`, binds its SHA-256 into the strict image manifest, and uploads both as one immutable artifact. Deploy/migration must verify artifact run/SHA, controller digest, archive paths/types/duplicates/size, current `main == staging` refs, and a complete Gitea compare commit-DAG path before mutation; any missing or inconsistent evidence fails closed. Manual rollback still uses the current reviewed controller, never the old target's controller. Preserve exact-SHA images, forward-only defaults, shared mutation lock, and bounded API/artifact requests.
|
||||
|
||||
## Fragment Sweep Command Set
|
||||
|
||||
## ERR-086 | Steve Jobs jyotishganit artifacts used non-San-Francisco coordinates | mitigated 2026-07-21
|
||||
|
||||
@@ -4,7 +4,8 @@ import { pathToFileURL } from "node:url";
|
||||
|
||||
const shaPattern = /^[0-9a-f]{40}$/;
|
||||
const digestPattern = /^sha256:[0-9a-f]{64}$/;
|
||||
const expectedKeys = ["git_sha", "api_digest", "web_digest"];
|
||||
const requiredKeys = ["git_sha", "api_digest", "web_digest"];
|
||||
const optionalKeys = ["controller_sha256"];
|
||||
const defaultRegistry = "ghcr.io/jesse-ux";
|
||||
const acrRepository = "crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha";
|
||||
const registryPattern = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)(?::[1-9][0-9]{0,4})?(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/;
|
||||
@@ -18,7 +19,7 @@ export function parseStagingImageManifest(text, expectedSha, registry = defaultR
|
||||
}
|
||||
|
||||
const lines = text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n");
|
||||
if (lines.length !== expectedKeys.length) {
|
||||
if (lines.length !== requiredKeys.length && lines.length !== requiredKeys.length + 1) {
|
||||
throw new Error("invalid staging image manifest");
|
||||
}
|
||||
|
||||
@@ -28,11 +29,14 @@ export function parseStagingImageManifest(text, expectedSha, registry = defaultR
|
||||
if (separator <= 0) throw new Error("invalid staging image manifest");
|
||||
const key = line.slice(0, separator);
|
||||
const value = line.slice(separator + 1);
|
||||
if (!expectedKeys.includes(key) || values.has(key)) {
|
||||
if (![...requiredKeys, ...optionalKeys].includes(key) || values.has(key)) {
|
||||
throw new Error("invalid staging image manifest");
|
||||
}
|
||||
values.set(key, value);
|
||||
}
|
||||
if (!requiredKeys.every((key) => values.has(key))) {
|
||||
throw new Error("invalid staging image manifest");
|
||||
}
|
||||
|
||||
if (values.get("git_sha") !== expectedSha) {
|
||||
throw new Error("staging image manifest revision mismatch");
|
||||
@@ -42,12 +46,17 @@ export function parseStagingImageManifest(text, expectedSha, registry = defaultR
|
||||
throw new Error("invalid staging image digest");
|
||||
}
|
||||
}
|
||||
const controllerSha256 = values.get("controller_sha256");
|
||||
if (controllerSha256 !== undefined && !/^[0-9a-f]{64}$/.test(controllerSha256)) {
|
||||
throw new Error("invalid staging controller digest");
|
||||
}
|
||||
|
||||
const sharedRepository = registry === acrRepository;
|
||||
return {
|
||||
gitSha: expectedSha,
|
||||
apiDigest: values.get("api_digest"),
|
||||
webDigest: values.get("web_digest"),
|
||||
...(controllerSha256 === undefined ? {} : { controllerSha256 }),
|
||||
apiImage: `${sharedRepository ? registry : `${registry}/jyotisha-api`}@${values.get("api_digest")}`,
|
||||
webImage: `${sharedRepository ? registry : `${registry}/jyotisha-web`}@${values.get("web_digest")}`,
|
||||
};
|
||||
@@ -73,6 +82,9 @@ if (invokedPath === import.meta.url) {
|
||||
`git_sha=${manifest.gitSha}`,
|
||||
`api_image=${manifest.apiImage}`,
|
||||
`web_image=${manifest.webImage}`,
|
||||
...(manifest.controllerSha256 === undefined
|
||||
? []
|
||||
: [`controller_sha256=${manifest.controllerSha256}`]),
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
@@ -208,9 +212,12 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest",
|
||||
assert.match(workflow, /xs=d if isinstance\(d,list\) else \[d\]/);
|
||||
assert.match(workflow, /get\("os"\)=="linux"/);
|
||||
assert.match(workflow, /get\("architecture"\)=="amd64"/);
|
||||
assert.match(workflow, /git archive --format=tar --output artifacts\/staging-images\/controller\.tar/);
|
||||
assert.match(workflow, /controller_sha256="\$\(sha256sum artifacts\/staging-images\/controller\.tar/);
|
||||
assert.match(workflow, /controller_sha256=%s/);
|
||||
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.match(workflow, /require\("\.\/\.gitea\/actions\/upload-artifact\/dist\/index\.js"\)/);
|
||||
assert.match(workflow, /--env INPUT_PATH=artifacts\/staging-images\/manifest\.env/);
|
||||
assert.match(workflow, /--env INPUT_PATH=artifacts\/staging-images\//);
|
||||
assert.match(workflow, /process\.env\["INPUT_IF-NO-FILES-FOUND"\]="error"/);
|
||||
assert.match(workflow, /process\.env\["INPUT_RETENTION-DAYS"\]="30"/);
|
||||
assert.match(workflow, /process\.env\["INPUT_COMPRESSION-LEVEL"\]="6"/);
|
||||
@@ -247,18 +254,52 @@ test("Gitea staging mutation workflows use the available runner and pinned Node
|
||||
assert.match(workflow, /--workdir "\$workdir"/);
|
||||
assert.match(workflow, /node:22-bookworm-slim "\$\{0##\*\/\}" "\$@"/);
|
||||
assert.match(workflow, />> "\$GITHUB_PATH"/);
|
||||
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.match(workflow, /artifacts\/(?:controller|staging-image)\/extracted\/frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.doesNotMatch(workflow, /packages\+=\((?:nodejs|npm)\)|apt-get install[^\n]*(?:nodejs|npm)/);
|
||||
assert.doesNotMatch(workflow, /(?:--volume|-v)[^\n]*(?:\$HOME\/\.docker|DOCKER_CONFIG)/);
|
||||
assertOrder(workflow, [
|
||||
"Checkout trusted main controller",
|
||||
"Prepare pinned Node tooling",
|
||||
"Download gate-produced",
|
||||
"node frontend/scripts/staging-image-manifest.mjs",
|
||||
"Download ",
|
||||
"controller_sha256",
|
||||
"extracted/frontend/scripts/staging-image-manifest.mjs",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -438,7 +479,7 @@ test("main remains the trusted GitHub deployment controller", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Gitea deploy and migration consume the exact successful gate artifact", () => {
|
||||
test("Gitea deploy and migration consume exact gate-attested controller bundles", () => {
|
||||
for (const workflow of [read(giteaDeployWorkflow), read(giteaMigrationWorkflow)]) {
|
||||
assert.match(workflow, /actions\/runs\?head_sha=\$[A-Z_]+&branch=staging&event=push&status=success/);
|
||||
assert.match(workflow, /\.path \| split\("@"\)\[0\] \| endswith\("backend-quality-gate\.yml"\)/);
|
||||
@@ -446,41 +487,34 @@ test("Gitea deploy and migration consume the exact successful gate artifact", ()
|
||||
assert.match(workflow, /\.event == "push"/);
|
||||
assert.match(workflow, /\.conclusion == "success"/);
|
||||
assert.match(workflow, /sort_by\(\.id\) \| reverse \| first/);
|
||||
assert.match(workflow, /actions\/runs\/\$GATE_RUN_ID\/artifacts\?limit=100/);
|
||||
assert.match(workflow, /actions\/artifacts\/\$artifact_id\/zip/);
|
||||
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.match(workflow, /name: Checkout trusted main controller/);
|
||||
assert.match(workflow, /git fetch --no-tags origin main "\$DEPLOY_SHA"/);
|
||||
assert.match(workflow, /git checkout --detach --force origin\/main/);
|
||||
assert.match(workflow, /git merge-base --is-ancestor "\$DEPLOY_SHA" HEAD/);
|
||||
assert.match(workflow, /actions\/runs\/\$(?:run_id|GATE_RUN_ID)\/artifacts\?limit=100/);
|
||||
assert.match(workflow, /actions\/artifacts\/\$(?:id|artifact_id)\/zip/);
|
||||
assert.match(workflow, /controller_sha256/);
|
||||
assert.match(workflow, /sha256sum --check --status/);
|
||||
assert.match(workflow, /allowed = \{"manifest\.env", "controller\.tar"\}/);
|
||||
assert.match(workflow, /unsafe staging artifact path/);
|
||||
assert.match(workflow, /unsafe staging artifact type/);
|
||||
assert.match(workflow, /3 \* 1024 \* 1024/);
|
||||
assert.match(workflow, /unsafe staging controller bundle/);
|
||||
assert.match(workflow, /extracted\/frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.doesNotMatch(workflow, /\bgit (?:fetch|ls-remote|cat-file|merge-base|checkout|init)\b/);
|
||||
assert.doesNotMatch(workflow, /docker manifest inspect/);
|
||||
assert.doesNotMatch(workflow, /\$IMAGE_REPOSITORY:(?:api|web)-\$DEPLOY_SHA/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Gitea staging mutations resolve the manifest from actual gate-run artifacts", () => {
|
||||
test("Gitea staging mutations resolve bundles from actual gate-run artifacts", () => {
|
||||
for (const workflow of [read(giteaDeployWorkflow), read(giteaMigrationWorkflow)]) {
|
||||
assert.match(
|
||||
workflow,
|
||||
/artifact_prefix="staging-image-manifest-\$DEPLOY_SHA-"/,
|
||||
);
|
||||
assert.match(workflow, /staging-image-manifest-\$(?:sha|DEPLOY_SHA)-/);
|
||||
assert.match(workflow, /\.expired == false/);
|
||||
assert.match(workflow, /\.name \| startswith\(\$prefix\)/);
|
||||
assert.match(workflow, /\.name \| ltrimstr\(\$prefix\)/);
|
||||
assert.match(workflow, /tonumber\?/);
|
||||
assert.match(workflow, /sort_by\(\.attempt, \.id\) \| reverse \| first/);
|
||||
assert.match(workflow, /artifact_name="\$\(jq -er '\.name'/);
|
||||
assert.match(workflow, /artifact_id="\$\(jq -er '\.id'/);
|
||||
assert.match(workflow, /artifact_attempt="\$\{artifact_name#"\$artifact_prefix"\}"/);
|
||||
assert.match(workflow, /\[\[ "\$artifact_name" == "\$artifact_prefix"\* \]\]/);
|
||||
assert.match(workflow, /\[\[ "\$artifact_attempt" =~ \^\[1-9\]\[0-9\]\*\$ \]\]/);
|
||||
assertOrder(workflow, [
|
||||
'artifact_prefix="staging-image-manifest-$DEPLOY_SHA-"',
|
||||
'actions/runs/$GATE_RUN_ID/artifacts?limit=100',
|
||||
'sort_by(.attempt, .id) | reverse | first',
|
||||
'artifact_name="$(jq -er',
|
||||
'actions/artifacts/$artifact_id/zip',
|
||||
]);
|
||||
assert.match(workflow, /jq -er '\.name'/);
|
||||
assert.match(workflow, /jq -er '\.id'/);
|
||||
assert.match(workflow, /actions\/artifacts\/\$(?:id|artifact_id)\/zip/);
|
||||
assert.match(workflow, /--connect-timeout 15 --max-time (?:60|120) --retry 3 --retry-all-errors/);
|
||||
assert.doesNotMatch(workflow, /run_attempt/i);
|
||||
assert.doesNotMatch(workflow, /staging-image-manifest-\$DEPLOY_SHA-1/);
|
||||
}
|
||||
@@ -497,8 +531,14 @@ test("Gitea deployment follows only a successful staging push gate and keeps rol
|
||||
assert.match(workflow, /concurrency:\n\s+group: staging-mutation\n\s+cancel-in-progress: false\n\s+queue: max/);
|
||||
assert.match(workflow, /rollback authorization is manual-only/);
|
||||
assert.match(workflow, /stale staging revision refused/);
|
||||
assert.match(workflow, /ALLOW_ROLLBACK: \$\{\{ steps\.revision\.outputs\.allow_rollback \}\}/);
|
||||
assert.match(workflow, /reviewed main and staging controller heads differ/);
|
||||
assert.match(workflow, /rollback revision is not in reviewed main history/);
|
||||
assert.match(workflow, /controller_gate_run_id/);
|
||||
assert.match(workflow, /def reaches\(\$sha; \$seen\)/);
|
||||
assert.match(workflow, /\.total_commits == \(\$commits \| length\)/);
|
||||
assert.match(workflow, /staging advanced during deployment; refusing stale mutation/);
|
||||
assert.match(workflow, /git merge-base --is-ancestor "\$previous_sha" "\$DEPLOY_SHA"/);
|
||||
assert.match(workflow, /automatic staging rollback or divergent deploy refused/);
|
||||
assert.match(workflow, /API_IMAGE: \$\{\{ steps\.images\.outputs\.api_image \}\}/);
|
||||
assert.match(workflow, /WEB_IMAGE: \$\{\{ steps\.images\.outputs\.web_image \}\}/);
|
||||
});
|
||||
@@ -510,6 +550,8 @@ test("Gitea migration remains manual and consumes only the gate-pinned web image
|
||||
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
|
||||
assert.match(workflow, /concurrency:\n\s+group: staging-mutation\n\s+cancel-in-progress: false\n\s+queue: max/);
|
||||
assert.match(workflow, /migration requires current staging head/);
|
||||
assert.match(workflow, /staging migration revision must equal reviewed main head/);
|
||||
assert.doesNotMatch(workflow, /--deepen=/);
|
||||
assert.match(workflow, /staging advanced during migration; refusing stale mutation/);
|
||||
assert.match(workflow, /WEB_IMAGE: \$\{\{ steps\.image\.outputs\.web_image \}\}/);
|
||||
assert.doesNotMatch(workflow, /API_IMAGE:/);
|
||||
|
||||
@@ -5,8 +5,9 @@ import { parseStagingImageManifest } from "../scripts/staging-image-manifest.mjs
|
||||
const gitSha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const apiDigest = `sha256:${"a".repeat(64)}`;
|
||||
const webDigest = `sha256:${"b".repeat(64)}`;
|
||||
const controllerSha256 = "c".repeat(64);
|
||||
|
||||
function validManifest(): string {
|
||||
function legacyManifest(): string {
|
||||
return [
|
||||
`git_sha=${gitSha}`,
|
||||
`api_digest=${apiDigest}`,
|
||||
@@ -15,11 +16,29 @@ function validManifest(): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function validManifest(): string {
|
||||
return legacyManifest().replace(
|
||||
`web_digest=${webDigest}\n`,
|
||||
`web_digest=${webDigest}\ncontroller_sha256=${controllerSha256}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
test("legacy image-only manifest remains valid for the GitHub artifact contract", () => {
|
||||
assert.deepEqual(parseStagingImageManifest(legacyManifest(), gitSha), {
|
||||
gitSha,
|
||||
apiDigest,
|
||||
webDigest,
|
||||
apiImage: `ghcr.io/jesse-ux/jyotisha-api@${apiDigest}`,
|
||||
webImage: `ghcr.io/jesse-ux/jyotisha-web@${webDigest}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("manifest produces immutable GHCR digest references", () => {
|
||||
assert.deepEqual(parseStagingImageManifest(validManifest(), gitSha), {
|
||||
gitSha,
|
||||
apiDigest,
|
||||
webDigest,
|
||||
controllerSha256,
|
||||
apiImage: `ghcr.io/jesse-ux/jyotisha-api@${apiDigest}`,
|
||||
webImage: `ghcr.io/jesse-ux/jyotisha-web@${webDigest}`,
|
||||
});
|
||||
@@ -32,6 +51,7 @@ test("manifest produces immutable shared ACR repository references", () => {
|
||||
gitSha,
|
||||
apiDigest,
|
||||
webDigest,
|
||||
controllerSha256,
|
||||
apiImage: `${repository}@${apiDigest}`,
|
||||
webImage: `${repository}@${webDigest}`,
|
||||
});
|
||||
@@ -42,6 +62,8 @@ test("manifest rejects revision drift, mutable tags, duplicates, extras, and mal
|
||||
validManifest().replace(gitSha, "f".repeat(40)),
|
||||
validManifest().replace(apiDigest, `${gitSha}`),
|
||||
validManifest().replace(apiDigest, `sha256:${"A".repeat(64)}`),
|
||||
validManifest().replace(controllerSha256, "C".repeat(64)),
|
||||
validManifest().replace(controllerSha256, "c".repeat(63)),
|
||||
validManifest().replace(
|
||||
`web_digest=${webDigest}`,
|
||||
`api_digest=${apiDigest}`,
|
||||
|
||||
Reference in New Issue
Block a user