Compare commits

...

10 Commits

Author SHA1 Message Date
Jesse 9c20efd16d fix(staging): retry exact gate checkout
Staging Backend Quality Gate / validate (pull_request) Successful in 18m30s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-06 20:13:59 +08:00
jesse 9f71e849a0 Merge pull request 'Close staging controller fetch incident' (#19)
Staging Backend Quality Gate / validate (push) Failing after 2m5s
Staging Backend Quality Gate / publish (push) Has been skipped
Record resolved gate-attested controller deployment evidence and retain browser handoff blockers.
2026-08-06 20:05:29 +08:00
Jesse 3f61188b07 docs(staging): close controller fetch incident 2026-08-06 20:04:47 +08:00
jesse e59f15d352 Merge pull request 'Deploy gate-attested staging controllers' (#18)
Staging Backend Quality Gate / validate (push) Successful in 13m53s
Staging Backend Quality Gate / publish (push) Successful in 10m17s
Replace mutation-time Git object fetches with exact-gate hash-bound controller bundles.
2026-08-06 19:33:47 +08:00
Jesse 8473146773 fix(staging): deploy gate-attested controllers
Staging Backend Quality Gate / validate (pull_request) Successful in 14m1s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-06 19:18:04 +08:00
jesse 784ee36cec Merge pull request 'Bound staging trusted-main fetch retries' (#17)
Staging Backend Quality Gate / validate (push) Successful in 14m3s
Staging Backend Quality Gate / publish (push) Successful in 5m36s
Fail closed on prolonged trusted-main fetches without changing exact-SHA or ancestry controls.
2026-08-06 18:14:55 +08:00
Jesse fa8698c95b docs(staging): close bounded fetch incident
Staging Backend Quality Gate / validate (pull_request) Successful in 12m46s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-06 18:00:40 +08:00
Jesse 52b467cbe5 docs(bugs): clarify recovered staging fetch stall
Staging Backend Quality Gate / validate (pull_request) Successful in 15m11s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-06 17:43:06 +08:00
Jesse 975f5c346d fix(staging): bound trusted main fetch retries 2026-08-06 17:40:41 +08:00
jesse 02cc483b7c Merge pull request 'Record personal report staging acceptance' (#16)
Staging Backend Quality Gate / validate (push) Successful in 13m48s
Staging Backend Quality Gate / publish (push) Successful in 4m59s
Record the tested staging evidence and explicit browser handoff blockers.
2026-08-06 17:03:14 +08:00
9 changed files with 413 additions and 116 deletions
+35 -7
View File
@@ -40,8 +40,20 @@ jobs:
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
git -c http.connectTimeout=15 -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 \
fetch --depth=1 --no-tags origin "$GITEA_SHA"
fetch_succeeded=false
for attempt in 1 2 3; do
if timeout 300 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 \
fetch --depth=1 --no-tags origin "$GITEA_SHA"; then
fetch_succeeded=true
break
fi
if [ "$attempt" -eq 3 ]; then
echo "exact staging gate checkout failed after $attempt bounded attempts" >&2
exit 1
fi
sleep $((attempt * 10))
done
[[ "$fetch_succeeded" == true ]]
git checkout --detach --force "$GITEA_SHA"
git clean -ffdx
test "$(git rev-parse HEAD)" = "$GITEA_SHA"
@@ -191,8 +203,20 @@ jobs:
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
git -c http.connectTimeout=15 -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 \
fetch --depth=1 --no-tags origin "$GITEA_SHA"
fetch_succeeded=false
for attempt in 1 2 3; do
if timeout 300 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 \
fetch --depth=1 --no-tags origin "$GITEA_SHA"; then
fetch_succeeded=true
break
fi
if [ "$attempt" -eq 3 ]; then
echo "exact staging gate checkout failed after $attempt bounded attempts" >&2
exit 1
fi
sleep $((attempt * 10))
done
[[ "$fetch_succeeded" == true ]]
git checkout --detach --force "$GITEA_SHA"
git clean -ffdx
test "$(git rev-parse HEAD)" = "$GITEA_SHA"
@@ -262,8 +286,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 +310,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 \
+157 -48
View File
@@ -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,44 +174,103 @@ 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
@@ -205,7 +298,12 @@ jobs:
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() {
@@ -218,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
+88 -23
View File
@@ -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,17 +141,65 @@ 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:
@@ -174,7 +223,12 @@ jobs:
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() {
@@ -187,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
+33 -1
View File
@@ -2223,4 +2223,36 @@
- 防复发:禁止 staging workflow 直接注入多行私钥或打印 decoded secret 变量;env owner 必须由部署树身份决定,root 受控脚本不得用 root 临时文件改变持久 env owner。任何凭据日志暴露先轮换/撤销/清理,再修代码和重跑。
- 相关记录:BUG-124、BUG-127、ERR-092、ERR-093、ERR-094
- 复发自:无
- 修复版本:待安全修复 PR 与 staging 验收
- 修复版本:`f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`staging migration/deploy 与安全验收完成
## BUG-129 | staging trusted-main checkout 无界 fetch 导致自动部署长期占用 mutation queue
- 状态:resolved
- 首次发现:2026-08-06
- 最近更新:2026-08-06
- 影响面:Gitea staging deploy/migration 控制器的 trusted-main checkoutproduction 与 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 closedSSH/远端 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 35/35、三份 YAML、解析后所有 shell/Python heredoc、真实 25-entry/122,880-byte controller tar hash/ZIP+TAR 安全检查、mutation Git-object-op=0、live Gitea commit-DAG、mandatory pre-work、ESLint、privacy 和 diff 检查通过;完整 PR gate `1481`、staging gate `1483` 成功。自动 deploy `1484` 在 1 分钟内成功部署 `e59f15d352787f3d05425ba8c459d092e9801a20`,日志 mutation `git fetch=0`、controller hash check 存在、SSH secret 遮蔽且无私钥材料;`main/staging/public/state` 精确一致,5 个容器 restart count 均为 0health、Swiss Ephemeris、未登录 401、`personal_reports`、RLS、2 条 owner policies、精确 migration ledger、authenticated SELECT/DELETE-only 与 service-role CRUD 均通过。
- 防复发:所有 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 第一轮修复未覆盖对象范围
- 修复版本:`e59f15d352787f3d05425ba8c459d092e9801a20`gate-attested controller bundle 已完成 staging exact-SHA 验收
## BUG-130 | staging quality gate exact-SHA checkout 因过严低速阈值单次失败
- 状态:investigating
- 首次发现:2026-08-06
- 最近更新:2026-08-06
- 影响面:Gitea `Staging Backend Quality Gate` validate/publish 的 exact-SHA checkoutstaging mutation controller、应用数据面与 production 未受影响。
- 用户现象:docs-attestation staging gate run `1485` 在 validate 的首步失败;Gitea 已枚举/压缩 3,246/2,895 个 shallow objects,但客户端传输降速后触发 `curl 28 Operation too slow``early EOF`。publish 被依赖关系跳过,自动 deploy 未触发;公网继续健康运行 `e59f15d352787f3d05425ba8c459d092e9801a20`
- 触发条件:quality gate 的 exact-SHA `--depth=1` fetch 只有单次调用,并把低速失败设为连续 30 秒低于 1024 B/s;当前 Gitea 链路在约 20 KiB/s 波动后短时低于阈值。
- 根因:`BUG-129` 消除了 mutation workflow 的 Git object fetch,但 quality gate 自身仍必须取得待测源码;其 checkout 没有 bounded retry,且低速阈值对当前受限链路过严。旧测试只断言 exact SHA/clean tree,没有覆盖 checkout retry 与低速边界。
- 修复:validate/publish 两处 exact-SHA checkout 均改为最多 3 次、每次 hard timeout 300 秒;保留 connect timeout 15 秒,将低速失败收紧为连续 60 秒低于 1 B/s。每次仍只抓 `--depth=1 --no-tags origin "$GITEA_SHA"`,耗尽后明确 fail closed,不复用旧 artifact、不放宽 exact-SHA 或 clean-tree 校验。
- 验证:待本地 workflow/YAML/run-script/pre-work 回归、完整 PR gate、staging push gate 和自动 deploy;完成前不得标记 resolved。
- 防复发:质量门禁和 mutation controller 的网络边界分别测试;quality gate checkout 必须覆盖 attempt 数、hard timeout、低速阈值、exact-SHA refspec、最终错误和 clean-tree identity。
- 相关记录:BUG-129、ERR-095、ERR-096
- 复发自:无;属于同一 Gitea 链路在 quality-gate 阶段的独立缺口
- 修复版本:待 bounded exact-SHA gate checkout 与 staging 验收
@@ -9,7 +9,9 @@ This record contains only release identities, aggregate operational evidence, sc
- Read-only upstream tree SHA-256: `9034e1967032d09c7fbae83fc2205f7e75e8ad482c5f9eba1bf309fe30aef5bb`
- Personal-report implementation merge: `e018dc90a73d49596563b5ae2b5fc203402cc86a`
- Security/control-plane merge: `f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`
- Staging migration and application deployment SHA: `f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`
- Staging migration and application-under-test SHA: `f7a615a5bf11ed95b3a6c7e6d28dfe8150a825ef`
- Docs-only acceptance attestation deployment SHA: `02cc483b7c303e6cc0f26fb31462c50adb007f12`
- Final gate-attested controller deployment SHA: `e59f15d352787f3d05425ba8c459d092e9801a20`
- 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.
@@ -37,7 +39,12 @@ The complete frontend suite could not be made fully executable on the local macO
- 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 SHA and `allow_rollback=false`.
- 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.
- First bounded-fetch staging gate `1479`: success; automatic deploy `1480` then exhausted all three 120-second full-history fetch attempts and failed closed before SSH. Public/state remained on the prior healthy SHA.
- Gate-attested controller PR gate `1481` and staging gate `1483`: success. Gate `1483` published the first four-field manifest plus SHA-256-bound `controller.tar` bundle for `e59f15d352787f3d05425ba8c459d092e9801a20`.
- Final automatic deploy `1484`: success in one minute. Mutation-time `git fetch` count was zero; controller hash validation ran; the staging SSH secret remained masked and no private-key material appeared. Public health, host state, Gitea `main`, and Gitea `staging` all matched the exact final SHA. See `BUG-129` / `ERR-095`.
## Security incident and containment
+12
View File
@@ -145,6 +145,18 @@ A failed exact-SHA staging deploy displayed the multiline staging SSH private ke
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 | mitigated 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. Verified by PR gate `1481`, staging gate `1483`, and one-minute exact-SHA deploy `1484`; mutation-time `git fetch` was zero and all post-deploy health/schema/permission checks passed.
## ERR-096 | Quality-gate exact-SHA checkout failed on a transient low-speed window | investigating 2026-08-06
Staging gate `1485` failed before validation when its single exact-SHA shallow fetch hit the configured 30-second/1024-B/s low-speed abort, producing `curl 28`, `early EOF`, and no publish artifact. No deployment was triggered and the previous exact-SHA staging application remained healthy. This is separate from mutation-time Git removal: quality validation still must acquire the source under test.
Prevention: both validate and publish exact-SHA checkouts use three bounded 300-second attempts, a 15-second connect timeout, and a 60-second/1-B/s stalled-transfer threshold. Preserve `--depth=1 --no-tags origin "$GITEA_SHA"`, exact HEAD equality, clean-tree checks, artifact non-reuse, and fail-closed exhaustion. Never report a skipped publish job as successful artifact publication.
## Fragment Sweep Command Set
## ERR-086 | Steve Jobs jyotishganit artifacts used non-San-Francisco coordinates | mitigated 2026-07-21
+15 -3
View File
@@ -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 {
@@ -141,6 +141,12 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest",
assert.match(workflow, /GITEA_SHA: \$\{\{ gitea\.sha \}\}/);
assert.equal((workflow.match(/git clean -ffdx/g) ?? []).length, 2);
assert.equal((workflow.match(/git status --porcelain --untracked-files=all/g) ?? []).length, 2);
assert.equal((workflow.match(/for attempt in 1 2 3/g) ?? []).length, 5);
assert.equal((workflow.match(/timeout 300 git -c http\.connectTimeout=15 -c http\.lowSpeedLimit=1 -c http\.lowSpeedTime=60/g) ?? []).length, 2);
assert.equal((workflow.match(/fetch --depth=1 --no-tags origin "\$GITEA_SHA"/g) ?? []).length, 2);
assert.equal((workflow.match(/exact staging gate checkout failed after \$attempt bounded attempts/g) ?? []).length, 2);
assert.equal((workflow.match(/\[\[ "\$fetch_succeeded" == true \]\]/g) ?? []).length, 2);
assert.doesNotMatch(workflow, /http\.lowSpeedLimit=1024|http\.lowSpeedTime=30/);
assert.match(workflow, /python3 -m venv \.venv/);
assert.match(workflow, /PIP_INDEX_URL: https:\/\/mirrors\.aliyun\.com\/pypi\/simple\//);
assert.match(workflow, /NPM_CONFIG_REGISTRY: https:\/\/registry\.npmmirror\.com/);
@@ -212,9 +218,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"/);
@@ -251,14 +260,14 @@ 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",
]);
}
});
@@ -476,7 +485,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"\)/);
@@ -484,41 +493,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/);
}
@@ -535,8 +537,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 \}\}/);
});
@@ -548,6 +556,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:/);
+23 -1
View File
@@ -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}`,