Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 73 additions & 18 deletions .github/workflows/enneagram-en13-evidence-production-ops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,49 @@ jobs:
|| ssh-keygen -F "[$DEPLOY_HOST]:$DEPLOY_PORT" -f ~/.ssh/known_hosts >/dev/null

- name: Run mandatory dry-run then optional bounded refresh
id: refresh
shell: bash
run: |
set -euo pipefail
set -uo pipefail
target="$DEPLOY_USER@$DEPLOY_HOST"
for value in DEPLOY_PATH RELEASE_SHA OPERATION_MODE COHORT_SHA256 EN_PACKAGE EN_LEDGER ZH_PACKAGE ZH_LEDGER; do
printf -v "q_${value,,}" '%q' "${!value}"
done
set +e
ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10 -p "$DEPLOY_PORT" "$target" \
"DEPLOY_PATH=$q_deploy_path RELEASE_SHA=$q_release_sha OPERATION_MODE=$q_operation_mode COHORT_SHA256=$q_cohort_sha256 EN_PACKAGE=$q_en_package EN_LEDGER=$q_en_ledger ZH_PACKAGE=$q_zh_package ZH_LEDGER=$q_zh_ledger bash -s" <<'REMOTE' > summary.json
set -Eeuo pipefail
stage="bootstrap"
tmp_dir="$(mktemp -d /tmp/en13-evidence-refresh.XXXXXX)"
dry_run_summary=null
write_summary=null
readback_summary=null
emit_summary() {
rc=$?
trap - EXIT
status=failure
failed_stage="$stage"
if [ "$rc" -eq 0 ]; then
status=success
failed_stage=""
fi
jq -n \
--arg schema_version "enneagram-en13-evidence-production-ops.v1" \
--arg status "$status" \
--arg release_sha "$RELEASE_SHA" \
--arg operation_mode "$OPERATION_MODE" \
--arg cohort_sha256 "${actual_cohort:-}" \
--arg failed_stage "$failed_stage" \
--argjson dry_run "$dry_run_summary" \
--argjson write "$write_summary" \
--argjson readback "$readback_summary" \
'{schema_version:$schema_version,status:$status,release_sha:$release_sha,operation_mode:$operation_mode,cohort_sha256:$cohort_sha256,failed_stage:(if $failed_stage == "" then null else $failed_stage end),dry_run:$dry_run,write:$write,readback:$readback}'
rm -rf "$tmp_dir"
exit "$rc"
}
trap emit_summary EXIT
cd "$DEPLOY_PATH/current"
stage="deployed_revision"
deployed_sha="$(tr -d '\r\n' < REVISION)"
test "$deployed_sha" = "$RELEASE_SHA"
cd backend
Expand Down Expand Up @@ -161,33 +193,56 @@ jobs:
echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), PHP_EOL;
'
}
run_refresh dry_run > /tmp/en13-evidence-dry-run.json
jq -e '.ok == true and (.status == "dry_run_ready" or .status == "already_refreshed") and .target_count == 26 and (.issues | length) == 0' /tmp/en13-evidence-dry-run.json >/dev/null
actual_cohort="$(jq -r '.cohort_sha256' /tmp/en13-evidence-dry-run.json)"
stage="dry_run_execution"
run_refresh dry_run > "$tmp_dir/dry-run.json"
dry_run_summary="$(jq -c '{ok,status,target_count,already_current_count,updated_count,issue_count:(.issues|length),negative_guarantees}' "$tmp_dir/dry-run.json")"
stage="dry_run_validation"
jq -e '.ok == true and (.status == "dry_run_ready" or .status == "already_refreshed") and .target_count == 26 and (.issues | length) == 0' "$tmp_dir/dry-run.json" >/dev/null
actual_cohort="$(jq -r '.cohort_sha256' "$tmp_dir/dry-run.json")"
if [ "$OPERATION_MODE" = write ]; then
stage="cohort_validation"
test "$actual_cohort" = "$COHORT_SHA256"
token="ENNEAGRAM-EN13-EVIDENCE-CMS-REFRESH-01:${RELEASE_SHA}:${COHORT_SHA256}"
ENNEAGRAM_EN13_EVIDENCE_WRITE_ENABLED=true run_refresh write "$COHORT_SHA256" "$token" > /tmp/en13-evidence-write.json
jq -e '.ok == true and (.status == "refreshed" or .status == "already_refreshed") and .target_count == 26 and (.issues | length) == 0' /tmp/en13-evidence-write.json >/dev/null
run_refresh dry_run > /tmp/en13-evidence-readback.json
jq -e '.ok == true and .status == "already_refreshed" and .target_count == 26 and .already_current_count == 26 and (.issues | length) == 0' /tmp/en13-evidence-readback.json >/dev/null
stage="write_execution"
ENNEAGRAM_EN13_EVIDENCE_WRITE_ENABLED=true run_refresh write "$COHORT_SHA256" "$token" > "$tmp_dir/write.json"
write_summary="$(jq -c '{ok,status,target_count,already_current_count,updated_count,issue_count:(.issues|length),negative_guarantees}' "$tmp_dir/write.json")"
stage="write_validation"
jq -e '.ok == true and (.status == "refreshed" or .status == "already_refreshed") and .target_count == 26 and (.issues | length) == 0' "$tmp_dir/write.json" >/dev/null
stage="readback_execution"
run_refresh dry_run > "$tmp_dir/readback.json"
readback_summary="$(jq -c '{ok,status,target_count,already_current_count,updated_count,issue_count:(.issues|length),negative_guarantees}' "$tmp_dir/readback.json")"
stage="readback_validation"
jq -e '.ok == true and .status == "already_refreshed" and .target_count == 26 and .already_current_count == 26 and (.issues | length) == 0' "$tmp_dir/readback.json" >/dev/null
fi
jq -n \
--arg schema_version "enneagram-en13-evidence-production-ops.v1" \
--arg release_sha "$RELEASE_SHA" --arg operation_mode "$OPERATION_MODE" --arg cohort_sha256 "$actual_cohort" \
--slurpfile dry_run /tmp/en13-evidence-dry-run.json \
--slurpfile write /tmp/en13-evidence-write.json 2>/dev/null \
--slurpfile readback /tmp/en13-evidence-readback.json 2>/dev/null \
'{schema_version:$schema_version,status:"success",release_sha:$release_sha,operation_mode:$operation_mode,cohort_sha256:$cohort_sha256,dry_run:($dry_run[0]|{ok,status,target_count,already_current_count,updated_count,issue_count:(.issues|length),negative_guarantees}),write:($write[0] // null),readback:($readback[0] // null)}' \
|| jq -n --arg schema_version "enneagram-en13-evidence-production-ops.v1" --arg release_sha "$RELEASE_SHA" --arg operation_mode "$OPERATION_MODE" --arg cohort_sha256 "$actual_cohort" --slurpfile dry_run /tmp/en13-evidence-dry-run.json \
'{schema_version:$schema_version,status:"success",release_sha:$release_sha,operation_mode:$operation_mode,cohort_sha256:$cohort_sha256,dry_run:($dry_run[0]|{ok,status,target_count,already_current_count,updated_count,issue_count:(.issues|length),negative_guarantees}),write:null,readback:null}'
rm -f /tmp/en13-evidence-dry-run.json /tmp/en13-evidence-write.json /tmp/en13-evidence-readback.json
stage="completed"
REMOTE
remote_exit=$?
set -e
if ! jq -e '.schema_version == "enneagram-en13-evidence-production-ops.v1" and (.status == "success" or .status == "failure")' summary.json >/dev/null 2>&1; then
jq -n \
--arg release_sha "$RELEASE_SHA" \
--arg operation_mode "$OPERATION_MODE" \
'{schema_version:"enneagram-en13-evidence-production-ops.v1",status:"failure",release_sha:$release_sha,operation_mode:$operation_mode,cohort_sha256:"",failed_stage:"transport_or_summary",dry_run:null,write:null,readback:null}' \
> summary.json
remote_exit=1
fi
echo "remote_exit=$remote_exit" >> "$GITHUB_OUTPUT"

- name: Upload sanitized aggregate summary
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: enneagram-en13-evidence-production-ops-${{ github.run_id }}
path: summary.json
if-no-files-found: error
retention-days: 14

- name: Enforce fail-closed refresh result
if: always()
shell: bash
env:
REMOTE_EXIT: ${{ steps.refresh.outputs.remote_exit }}
run: |
set -euo pipefail
test "$REMOTE_EXIT" = "0"
jq -e '.status == "success" and .failed_stage == null' summary.json >/dev/null
25 changes: 24 additions & 1 deletion backend/app/Services/Cms/EnneagramCmsDraftWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -296,14 +296,37 @@ private function evidenceRefreshStateIsSafe(PersonalityPublicContentAsset $asset
private function evidenceRefreshMatches(PersonalityPublicContentAsset $asset, array $candidate): bool
{
foreach ($candidate as $field => $value) {
if ($asset->getAttribute($field) !== $value) {
$actual = $asset->getAttribute($field);
if (is_array($value)) {
if (! is_array($actual) || $this->canonicalEvidenceJsonValue($actual) !== $this->canonicalEvidenceJsonValue($value)) {
return false;
}

continue;
}

if ($actual !== $value) {
return false;
}
}

return true;
}

private function canonicalEvidenceJsonValue(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->canonicalEvidenceJsonValue($item), $value);
}

ksort($value, SORT_STRING);

return array_map(fn (mixed $item): mixed => $this->canonicalEvidenceJsonValue($item), $value);
}

private function evidenceVisibleLength(mixed $value): int
{
return is_scalar($value) ? mb_strlen(trim(strip_tags((string) $value))) : 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Models\PersonalityPublicContentAsset;
use App\Services\Cms\EnneagramCmsDraftWriter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Tests\TestCase;

Expand Down Expand Up @@ -78,6 +79,40 @@ public function test_write_fails_closed_without_process_gate_or_exact_cohort():
$this->assertNotSame('', $cohort);
}

public function test_mysql_json_object_key_normalization_is_semantically_idempotent_while_scalars_remain_exact(): void
{
$this->seedPublishedAssets();
$dryRun = $this->evaluate();
$cohort = $dryRun['cohort_sha256'];
$token = 'ENNEAGRAM-EN13-EVIDENCE-CMS-REFRESH-01:'.self::DEPLOYED_SHA.':'.$cohort;
$write = $this->evaluate(true, $cohort, $token, true);
$this->assertSame('refreshed', $write['status']);

foreach (PersonalityPublicContentAsset::query()->orderBy('id')->get() as $asset) {
DB::table('personality_public_content_assets')->where('id', $asset->id)->update([
'content_sections_json' => json_encode(
self::reverseAssociativeObjectKeys((array) $asset->content_sections_json),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
),
'evidence_notes_json' => json_encode(
self::reverseAssociativeObjectKeys((array) $asset->evidence_notes_json),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
),
]);
}

$semanticReadback = $this->evaluate();
$this->assertSame('already_refreshed', $semanticReadback['status']);
$this->assertSame(26, $semanticReadback['already_current_count']);

DB::table('personality_public_content_assets')->where('id', 1)->update([
'source_hash' => str_repeat('f', 64),
]);
$scalarMismatch = $this->evaluate();
$this->assertSame('dry_run_ready', $scalarMismatch['status']);
$this->assertSame(25, $scalarMismatch['already_current_count']);
}

public function test_dry_run_rejects_partial_or_unsafe_production_cohort(): void
{
$this->seedPublishedAssets();
Expand Down Expand Up @@ -128,19 +163,39 @@ public function test_production_workflow_is_sha_cohort_bound_dry_run_first_and_h
$this->assertStringContainsString('Approve ENNEAGRAM EN13 evidence refresh dry-run for SHA ${RELEASE_SHA}', $workflow);
$this->assertStringContainsString('I explicitly approve ENNEAGRAM EN13 evidence refresh for SHA ${RELEASE_SHA} cohort ${COHORT_SHA256}.', $workflow);
$this->assertStringContainsString('test "$deployed_sha" = "$RELEASE_SHA"', $workflow);
$this->assertStringContainsString('run_refresh dry_run > /tmp/en13-evidence-dry-run.json', $workflow);
$this->assertStringContainsString('tmp_dir="$(mktemp -d /tmp/en13-evidence-refresh.XXXXXX)"', $workflow);
$this->assertStringContainsString('trap emit_summary EXIT', $workflow);
$this->assertStringContainsString('run_refresh dry_run > "$tmp_dir/dry-run.json"', $workflow);
$this->assertStringContainsString('ENNEAGRAM_EN13_EVIDENCE_WRITE_ENABLED=true run_refresh write', $workflow);
$this->assertStringContainsString('.status == "already_refreshed"', $workflow);
$this->assertStringContainsString('failed_stage:"transport_or_summary"', $workflow);
$this->assertStringContainsString('if: always()', $workflow);
$this->assertStringContainsString('Enforce fail-closed refresh result', $workflow);
$this->assertStringNotContainsString('/tmp/en13-evidence-dry-run.json', $workflow);
$this->assertLessThan(
strpos($workflow, 'ENNEAGRAM_EN13_EVIDENCE_WRITE_ENABLED=true run_refresh write'),
strpos($workflow, 'run_refresh dry_run > /tmp/en13-evidence-dry-run.json')
strpos($workflow, 'run_refresh dry_run > "$tmp_dir/dry-run.json"')
);
$this->assertStringNotContainsString('seo:warm-sitemap-source-cache', $workflow);
$this->assertStringNotContainsString('IndexNow', $workflow);
$this->assertStringNotContainsString('llms_eligible=true', $workflow);
$this->assertStringNotContainsString('deploy production', $workflow);
}

private static function reverseAssociativeObjectKeys(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(self::reverseAssociativeObjectKeys(...), $value);
}

krsort($value, SORT_STRING);

return array_map(self::reverseAssociativeObjectKeys(...), $value);
}

private function seedPublishedAssets(): void
{
foreach (['en-package', 'zh-package'] as $key) {
Expand Down
Loading