From 89e9c8263846e42bc402366a7853a00cead43b54 Mon Sep 17 00:00:00 2001 From: shigurenimo Date: Wed, 28 Oct 2026 16:20:34 +0900 Subject: [PATCH] fix(ci): publish only from exact clean release verification evidence --- .docs/catalog.json | 7 ++- .docs/core/delivery.md | 2 +- .docs/production-deployment.md | 4 +- .github/workflows/verify.yml | 2 + package.json | 5 +- scripts/check-verification.mjs | 15 +++++ scripts/deploy-production.mjs | 19 +++---- scripts/verification-evidence.mjs | 42 ++++++++++++++ scripts/verification-evidence.test.mjs | 77 ++++++++++++++++++++++++++ 9 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 scripts/check-verification.mjs create mode 100644 scripts/verification-evidence.mjs create mode 100644 scripts/verification-evidence.test.mjs diff --git a/.docs/catalog.json b/.docs/catalog.json index 9f9f63d..238a72f 100644 --- a/.docs/catalog.json +++ b/.docs/catalog.json @@ -493,13 +493,16 @@ "apps/app/src/api/api.ts", "scripts/deploy-production.mjs", "package.json", - ".github/workflows/verify.yml" + ".github/workflows/verify.yml", + "scripts/check-verification.mjs", + "scripts/verification-evidence.mjs" ], "verification": [ "apps/site/src/utils/setup-access.test.mjs", "test/local.spec.ts", "test/site-content.spec.ts", - "test/invitations.spec.ts" + "test/invitations.spec.ts", + "scripts/verification-evidence.test.mjs" ], "source": "User requested integration of preserved local work with current main; implementation choices and historical production observations are distinguished in prose." }, diff --git a/.docs/core/delivery.md b/.docs/core/delivery.md index fb9c784..88283d2 100644 --- a/.docs/core/delivery.md +++ b/.docs/core/delivery.md @@ -6,4 +6,4 @@ Commit verified responsibilities with Conventional Commits. Before an authorized Actual tags, releases, production deployment, credentials, permission changes and database restores require explicit authorization for those actions. A release must be tied to an immutable SHA and passing checks. Rollback requires the previously deployed artifact and a verified database compatibility/restore plan; a Git revert alone cannot undo persisted writes. Never blindly auto-publish or auto-merge policy changes. -Production uses one protected `release` branch. Promote reviewed changes through a pull request and use `vp run deploy:production` only on its clean, CI-verified commit after explicit deployment authorization. The command re-runs full verification and secret scanning before using local Cloudflare authentication. It does not add CI deployment credentials or publish automatically. See [[production-deployment]]. +Production uses one protected `release` branch. Promote reviewed changes through a pull request and use `vp run deploy:production` only on its clean, CI-verified commit after explicit deployment authorization. The release push workflow runs full verification and confirms evidence for the exact clean commit. The publication command validates that workflow and its required steps, then scans local secrets before using local Cloudflare authentication. It does not add CI deployment credentials or publish automatically. See [[production-deployment]]. diff --git a/.docs/production-deployment.md b/.docs/production-deployment.md index 43b084d..5405804 100644 --- a/.docs/production-deployment.md +++ b/.docs/production-deployment.md @@ -31,10 +31,12 @@ PUBLIC_APP_URL=https://app.stack.open.inta.site PORTLESS_URL=https://stack.open. 2026-10-28、ユーザーは公開用に `release` ブランチを1つ使う方針を選んだ。通常の開発は `main`、公開する変更はプルリクエストで `release` に取り込む。`release` は管理者にも適用する保護を設け、最新の `verify` 必須チェックとプルリクエストを要求し、強制プッシュと削除を許可しない。単独運用のため別の人の承認人数は要求しない。レビューで変更と検証結果を確認してから取り込む。 -GitHubの検証は `main` と `release` へのpush、およびプルリクエストで実行する。公開は明示的な依頼を受け、クリーンな `release` のチェックアウトで `vp run deploy:production` を実行する。このコマンドは公開先ブランチの保護・リモートSHA・同一SHAのCI成功を確認し、全E2Eを含む検証と秘密情報検査を実行してから、既存のCloudflare認証でアプリとサイトを公開する。複数アカウントがある端末でも既存の公開先アカウントを明示して選択する。GitHubへCloudflareの秘密情報を追加せず、自動公開は行わない。必要な依存関係、ブラウザと検査道具は通常のセットアップ手順で準備する。[[core-verification]]、[[core-delivery]]。 +GitHubの検証は `main` と `release` へのpush、およびプルリクエストで実行する。公開は明示的な依頼を受け、クリーンな `release` のチェックアウトで `vp run deploy:production` を実行する。このコマンドは公開先ブランチの保護・リモートSHA・同一SHAのrelease pushのCI成功と実行証跡を確認し、端末で秘密情報検査を実行してから、既存のCloudflare認証でアプリとサイトを公開する。CIでは全E2Eを含む検証を実行し、3つのブラウザ試験群・完全検証・クリーンなコミット・同一SHAの秘密情報検査を明示的な追加ステップで確認する。複数アカウントがある端末でも既存の公開先アカウントを明示して選択する。GitHubへCloudflareの秘密情報を追加せず、自動公開は行わない。必要な依存関係、ブラウザと検査道具は通常のセットアップ手順で準備する。[[core-verification]]、[[core-delivery]]。 アプリとサイトは順に公開されるため、片方だけ成功する可能性がある。失敗時は実際の公開バージョンとデータ互換性を調べ、成功した部分も含めて報告する。コマンドはDBを復元しない。移行がある場合は公開前に内容・互換性・回復手順を確認する。以前の直接公開コマンドは過去の手順として残し、通常の公開入口はこの検証付きコマンドとする。設定の存在と実際のGitHub保護・本番応答は別々に確認する。 公開経路の検証中、ブラウザの初期化より先にエラー表示の確認を始める競合、別領域に同時に出た警告を区別しない招待試験、CMS操作と全リンク巡回が1つの時間枠を共有する問題を観測した。初期化試験は合成した503応答を待ってから同じエラー表示・操作禁止・復旧を確認する。招待試験はメンバーダイアログ内の警告と再読み込みを対象にする。CMSは操作・公開と全リンク巡回を直列の別試験に分け、各試験の制限時間、全リンク、HTTP状態、ガイド到達性の期待値を維持する。ローカルD1の一時的な内部エラーそのものを修正したとは扱わない。公開前の全試験と秘密情報検査は引き続き必須とする。 公開直前の再確認では、ブランチ名・クリーン状態・リモートSHAに加え、端末のHEADも検証開始時のSHAと一致することを要求する。検証後に別のコミットへ移動した場合は、同じブランチ名でも公開しない。 + +公開前の全検証は、ポートやローカルデータを共有しないGitHubの専用環境を正本とする。端末で通常開発用のポートを使うブラウザ試験を繰り返す方式は、別プロジェクトの開発サーバーと衝突したため採用しない。公開コマンドはreleaseへのpushで実行された所定のワークフローだけを認め、全検証・秘密情報検査・クリーンな同一コミットの確認の各ステップが成功していなければ停止する。mainやPRの成功だけ、部分検証、ステップ省略、別SHAや変更済み作業領域の証跡では公開しない。端末のローカル検証は開発中の確認に使い、他のアプリを停止して公開条件を整える運用にはしない。 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 6da7865..e4f9832 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -73,3 +73,5 @@ jobs: run: vp exec playwright install chromium - name: Full isolated verification run: vp run verify:full + - name: Confirm exact clean verification + run: vp run check:verification diff --git a/package.json b/package.json index 341a889..37fe810 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,13 @@ "check:architecture": "node scripts/check-architecture.mjs", "check:docs": "node scripts/check-docs.mjs", "check:secrets": "node scripts/check-secrets.mjs", - "test:harness": "node --test scripts/architecture-policy.test.mjs scripts/docs-policy.test.mjs scripts/release-candidate.test.mjs scripts/line-endings.test.mjs scripts/assert-proxy-port.test.mjs scripts/dev-body-limit.test.mjs", + "test:harness": "node --test scripts/architecture-policy.test.mjs scripts/docs-policy.test.mjs scripts/release-candidate.test.mjs scripts/line-endings.test.mjs scripts/assert-proxy-port.test.mjs scripts/dev-body-limit.test.mjs scripts/verification-evidence.test.mjs", "verify": "node scripts/verify.mjs", "verify:full": "node scripts/verify.mjs --full", "release:plan": "node scripts/release-plan.mjs", "setup:scanner": "node scripts/setup-scanner.mjs", - "deploy:production": "node scripts/deploy-production.mjs" + "deploy:production": "node scripts/deploy-production.mjs", + "check:verification": "node scripts/check-verification.mjs" }, "devDependencies": { "@babel/parser": "7.29.11", diff --git a/scripts/check-verification.mjs b/scripts/check-verification.mjs new file mode 100644 index 0000000..49763db --- /dev/null +++ b/scripts/check-verification.mjs @@ -0,0 +1,15 @@ +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" +import { assertFullVerification } from "./verification-evidence.mjs" + +const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim() +const sha = git("rev-parse", "HEAD") +if (process.env.GITHUB_ACTIONS === "true" && sha !== process.env.GITHUB_SHA) + throw new Error("CI checkout must match the workflow commit") +assertFullVerification({ + sha, + clean: !git("status", "--porcelain"), + receipt: JSON.parse(readFileSync(".local/verification.json", "utf8")), + secretReceipt: JSON.parse(readFileSync(".local/secret-receipt.json", "utf8")), +}) +console.log(`Exact clean commit verification confirmed: ${sha}`) diff --git a/scripts/deploy-production.mjs b/scripts/deploy-production.mjs index b124d87..901e5b3 100644 --- a/scripts/deploy-production.mjs +++ b/scripts/deploy-production.mjs @@ -1,6 +1,7 @@ import { execFileSync, spawnSync } from "node:child_process" import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" +import { assertReleaseRun, assertReleaseJob } from "./verification-evidence.mjs" const repository = "interactive-inc/open-stack-void" const read = (command, args) => execFileSync(command, args, { encoding: "utf8" }).trim() @@ -24,12 +25,11 @@ function assertTarget() { protection.allow_deletions?.enabled ) throw new Error("Release branch protections are incomplete") - const checks = api(`commits/${sha}/check-runs`).check_runs - const verification = checks.find( - (check) => check.name === "verify" && check.app?.slug === "github-actions", - ) - if (verification?.status !== "completed" || verification.conclusion !== "success") - throw new Error("The exact release commit needs a successful GitHub verify check") + const workflow = api( + `actions/workflows/verify.yml/runs?branch=release&head_sha=${sha}&event=push&per_page=1`, + ).workflow_runs[0] + assertReleaseRun(workflow, sha) + assertReleaseJob(api(`actions/runs/${workflow.id}/jobs?filter=latest&per_page=100`).jobs) } const vp = fileURLToPath(new URL("../node_modules/vite-plus/bin/vp", import.meta.url)) function run(args, extraEnv = {}) { @@ -45,13 +45,10 @@ function run(args, extraEnv = {}) { if (result.status !== 0) throw new Error(`Production release stopped: vp ${args.join(" ")}`) } assertTarget() -run(["run", "verify:full"]) run(["run", "check:secrets"]) -const verification = JSON.parse(readFileSync(".local/verification.json", "utf8")) const secrets = JSON.parse(readFileSync(".local/secret-receipt.json", "utf8")) -if (verification.sha !== sha || !verification.clean || !verification.full || secrets.sha !== sha) - throw new Error("Verification receipts must match the exact clean release commit") -// Recheck GitHub and the checkout after the lengthy verification, before any production write. +if (secrets.sha !== sha) throw new Error("Secret scan must match the exact release commit") +// Recheck GitHub and the checkout after secret scanning, before any production write. assertTarget() console.log(`Deploying verified release ${sha}`) run(["-C", "apps/app", "exec", "void", "deploy", "--platform", "cloudflare"], { diff --git a/scripts/verification-evidence.mjs b/scripts/verification-evidence.mjs new file mode 100644 index 0000000..571f185 --- /dev/null +++ b/scripts/verification-evidence.mjs @@ -0,0 +1,42 @@ +const suites = ["test:e2e", "test:e2e:cms", "test:e2e:portless"] +export const releaseSteps = [ + "Secret scan without printing findings", + "Full isolated verification", + "Confirm exact clean verification", +] + +export function assertFullVerification({ sha, clean, receipt, secretReceipt }) { + if (!clean || receipt?.sha !== sha || receipt.clean !== true || receipt.full !== true) + throw new Error("Full verification must match the exact clean commit") + if ( + !suites.every((suite) => + receipt.commands?.some((command) => command[0] === "run" && command[1] === suite), + ) + ) + throw new Error("Verification evidence must include every browser suite") + if (secretReceipt?.sha !== sha) throw new Error("Secret scan must match the verified commit") +} + +export function assertReleaseRun(run, sha) { + if ( + !Number.isSafeInteger(run?.id) || + run.head_sha !== sha || + run.head_branch !== "release" || + run.path !== ".github/workflows/verify.yml" || + run.event !== "push" || + run.status !== "completed" || + run.conclusion !== "success" + ) + throw new Error("The exact release commit needs a successful release verification workflow") +} + +export function assertReleaseJob(jobs) { + const job = jobs.find((candidate) => candidate.name === "verify") + if ( + job?.conclusion !== "success" || + !releaseSteps.every((name) => + job.steps?.some((step) => step.name === name && step.conclusion === "success"), + ) + ) + throw new Error("Release CI must prove full verification, secret scanning and a clean commit") +} diff --git a/scripts/verification-evidence.test.mjs b/scripts/verification-evidence.test.mjs new file mode 100644 index 0000000..89d0237 --- /dev/null +++ b/scripts/verification-evidence.test.mjs @@ -0,0 +1,77 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + assertFullVerification, + assertReleaseRun, + assertReleaseJob, + releaseSteps, +} from "./verification-evidence.mjs" + +const sha = "a".repeat(40) +const receipt = { + sha, + clean: true, + full: true, + commands: ["test:e2e", "test:e2e:cms", "test:e2e:portless"].map((suite) => ["run", suite]), +} +const input = { sha, clean: true, receipt, secretReceipt: { sha } } +const run = { + id: 1, + head_sha: sha, + head_branch: "release", + path: ".github/workflows/verify.yml", + event: "push", + status: "completed", + conclusion: "success", +} +const job = { + name: "verify", + conclusion: "success", + steps: releaseSteps.map((name) => ({ name, conclusion: "success" })), +} + +test("CI evidence rejects dirty, stale, partial or missing browser verification", () => { + assertFullVerification(input) + for (const patch of [ + { clean: false }, + { receipt: { ...receipt, clean: false } }, + { receipt: { ...receipt, sha: "b".repeat(40) } }, + { receipt: { ...receipt, full: false } }, + { receipt: { ...receipt, commands: receipt.commands.slice(0, 2) } }, + { secretReceipt: { sha: "b".repeat(40) } }, + ]) + assert.throws(() => assertFullVerification({ ...input, ...patch })) +}) + +test("main, pull request, pending, failed and unrelated workflow runs cannot authorize a release", () => { + assertReleaseRun(run, sha) + for (const patch of [ + { head_sha: "b".repeat(40) }, + { head_branch: "main" }, + { path: ".github/workflows/other.yml" }, + { event: "pull_request" }, + { status: "in_progress" }, + { conclusion: "failure" }, + { conclusion: "cancelled" }, + ]) + assert.throws(() => assertReleaseRun({ ...run, ...patch }, sha)) +}) + +test("a successful job cannot authorize publication when a required verification step was skipped", () => { + assertReleaseJob([job]) + for (const name of releaseSteps) { + assert.throws(() => + assertReleaseJob([{ ...job, steps: job.steps.filter((step) => step.name !== name) }]), + ) + assert.throws(() => + assertReleaseJob([ + { + ...job, + steps: job.steps.map((step) => + step.name === name ? { ...step, conclusion: "skipped" } : step, + ), + }, + ]), + ) + } +})