From f4a7dc463171d511280f70a654b65fe363dd2fe8 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:01:52 +0400 Subject: [PATCH] fix: preserve deployment failure evidence --- README.md | 2 +- apps/backend/README.md | 4 +- apps/backend/scripts/deploy-lib.sh | 20 ++ apps/backend/scripts/deploy-production.sh | 30 ++- .../scripts/deploy-target.integration.mjs | 174 +++++++++++++++--- apps/backend/scripts/deploy-target.sh | 66 ++++--- docs/network-blip-rollout-handoff.md | 2 +- docs/semantic-elements-handoff.md | 13 +- docs/unattended-production-rollout.md | 22 ++- 9 files changed, 258 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 0e65eec..08386e2 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The Release workflow publishes the promoted versions with npm provenance. It rej Do not merge `master` back into `dev`. `NPM_TOKEN` needs publish access to the `@understudy` scope. -The Deploy workflow updates staging after every `dev` push. After the one-time protocol-3 cutover, it updates production after every `master` push. Production deployment first rebuilds the store extension and requires its normalized contents to match `apps/extension/store-release.json` in the `published` state. +The Deploy workflow attempts a staging update after every `dev` push. The `staging` GitHub environment must provide its scoped `CLOUDFLARE_API_TOKEN` before the upload can run. After the one-time protocol-3 cutover, the workflow updates production after every `master` push. Production deployment first rebuilds the store extension and requires its normalized contents to match `apps/extension/store-release.json` in the `published` state. Use the guarded production wrapper for the first protocol-3 cutover and any later compatibility-contract change. It validates the protocol-3 device map, published extension, canary credential, immutable source snapshot, Worker provenance, and deployment evidence. Follow the [production rollout runbook](docs/unattended-production-rollout.md). diff --git a/apps/backend/README.md b/apps/backend/README.md index 2d90b58..45c1ec2 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -173,9 +173,9 @@ pnpm --filter @understudy/backend deploy:staging The command records local dirty provenance and writes evidence under `/tmp`. The next `dev` deployment can replace the local deployment. -Before the first `dev` merge, create a GitHub `staging` environment restricted to `dev`, add a staging-scoped `CLOUDFLARE_API_TOKEN`, and run `provision:staging`. Create a separate `production` environment restricted to `master`, add a production-scoped token, and keep `PRODUCTION_AUTODEPLOY_ENABLED=false` until the manual compatibility cutover has passed. Workflow deployment tokens are exposed only to their deployment step. +The GitHub `staging` environment exists, but its secret inventory was empty when last checked on 2026-08-04. Before rerunning the failed staging deployment, restrict the environment to `dev`, add a staging-scoped `CLOUDFLARE_API_TOKEN`, and run `provision:staging`. The separate `production` environment also exists with an empty secret inventory; restrict it to `master`, add a production-scoped token before production deployment, and keep `PRODUCTION_AUTODEPLOY_ENABLED=false` until the manual compatibility cutover has passed. Workflow deployment tokens are exposed only to their deployment step. -Every deployment writes an `attempting`, `failed`, or `verified` evidence artifact. Failed post-upload evidence includes `priorDeployment`, the exact deployment state captured before upload. Recover staging with its prior 100% version: +After the mode and evidence path are validated, every automated deployment writes an `attempting`, `failed`, or `verified` evidence artifact. Build, dry-run, prior-deployment lookup, source-ref, upload, and verification failures identify their stage. Failed post-upload evidence includes `priorDeployment`, the exact deployment state captured before upload. Recover staging with its prior 100% version: ```bash previous_version="$(jq -r '.priorDeployment.versions[] | select(.percentage == 100) | .version_id' /absolute/path/staging-deployment.json)" diff --git a/apps/backend/scripts/deploy-lib.sh b/apps/backend/scripts/deploy-lib.sh index d6e3e80..3b75c72 100644 --- a/apps/backend/scripts/deploy-lib.sh +++ b/apps/backend/scripts/deploy-lib.sh @@ -53,6 +53,26 @@ understudy_deploy_dry_run() { fi } +understudy_json_or_null() { + local value="${1:-null}" + if jq -e -n --argjson value "$value" '$value | type' >/dev/null 2>&1; then + printf '%s' "$value" + else + printf 'null' + fi +} + +understudy_require_json_type() { + local value="$1" + local expected_type="$2" + local label="$3" + if ! jq -e -n --argjson value "$value" --arg expected "$expected_type" \ + '$value | type == $expected' >/dev/null 2>&1; then + echo "$label did not return a JSON $expected_type" >&2 + return 1 + fi +} + understudy_versions_json() { understudy_wrangler_control_plane versions list --json } diff --git a/apps/backend/scripts/deploy-production.sh b/apps/backend/scripts/deploy-production.sh index a3ef608..6ba2353 100755 --- a/apps/backend/scripts/deploy-production.sh +++ b/apps/backend/scripts/deploy-production.sh @@ -151,10 +151,8 @@ if [[ "$confirmation" != "DEPLOY" ]]; then exit 1 fi -assert_source_unchanged -assert_current_master_head -UNDERSTUDY_PRIOR_DEPLOYMENT="$(understudy_wrangler_control_plane deployments status --json)" -prior_versions="$(understudy_versions_json)" +UNDERSTUDY_PRIOR_DEPLOYMENT='null' +prior_versions='null' health='null' active_version='null' source_release='null' @@ -162,7 +160,7 @@ deployment='null' device_tokens_secret_version='null' extension_id_secret_version='null' secret_derived='null' -secret_mutation_possible='true' +secret_mutation_possible='false' deployment_stage="prepared" write_evidence() { @@ -227,6 +225,18 @@ record_failed_deployment() { local exit_code="$?" trap - EXIT set +e + compatibility_config="$(understudy_json_or_null "$compatibility_config")" + production_contract="$(understudy_json_or_null "$production_contract")" + store_release="$(understudy_json_or_null "$store_release")" + health="$(understudy_json_or_null "$health")" + source_release="$(understudy_json_or_null "$source_release")" + active_version="$(understudy_json_or_null "$active_version")" + deployment="$(understudy_json_or_null "$deployment")" + UNDERSTUDY_PRIOR_DEPLOYMENT="$(understudy_json_or_null "$UNDERSTUDY_PRIOR_DEPLOYMENT")" + prior_versions="$(understudy_json_or_null "$prior_versions")" + device_tokens_secret_version="$(understudy_json_or_null "$device_tokens_secret_version")" + extension_id_secret_version="$(understudy_json_or_null "$extension_id_secret_version")" + secret_derived="$(understudy_json_or_null "$secret_derived")" write_evidence "failed" "$deployment_stage" "$exit_code" cleanup exit "$exit_code" @@ -234,6 +244,15 @@ record_failed_deployment() { write_evidence "attempting" trap record_failed_deployment EXIT +deployment_stage="source-ref" +assert_source_unchanged +assert_current_master_head +deployment_stage="prior-deployment" +UNDERSTUDY_PRIOR_DEPLOYMENT="$(understudy_wrangler_control_plane deployments status --json)" +understudy_require_json_type "$UNDERSTUDY_PRIOR_DEPLOYMENT" object "prior deployment" +prior_versions="$(understudy_versions_json)" +understudy_require_json_type "$prior_versions" array "prior version inventory" +secret_mutation_possible='true' deployment_stage="device-token-secret" understudy_with_cloudflare_auth node "$backend_dir/scripts/put-validated-secret.mjs" \ DEVICE_TOKENS "$device_tokens_sha256" <"$device_tokens_path" @@ -252,6 +271,7 @@ extension_id_secret_version="$( '{before: $before, after: $after}' | node "$backend_dir/scripts/secret-version.mjs" )" +deployment_stage="source-ref" assert_source_unchanged assert_current_master_head deployment_stage="upload" diff --git a/apps/backend/scripts/deploy-target.integration.mjs b/apps/backend/scripts/deploy-target.integration.mjs index 855f785..a64adb2 100644 --- a/apps/backend/scripts/deploy-target.integration.mjs +++ b/apps/backend/scripts/deploy-target.integration.mjs @@ -22,7 +22,7 @@ describe("deployment target integration", () => { const fixture = await deploymentFixture({ FAKE_SYSTEM_DNS: "missing" }); const result = runDeployment(fixture); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 0, commandFailure(result)); const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); assert.equal(evidence.outcome, "verified"); assert.equal(evidence.activeWorkerVersion.id, "v1"); @@ -43,8 +43,7 @@ describe("deployment target integration", () => { assert.match(result.stderr, /expected source provenance/); assert.match(await readFile(fixture.log, "utf8"), /deploy --strict/); const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); - assert.equal(evidence.outcome, "failed"); - assert.equal(evidence.failureStage, "verification"); + assertFailedEvidence(evidence, result, "verification"); assert.equal(evidence.priorDeployment.versions[0].version_id, "v0"); }); @@ -55,33 +54,59 @@ describe("deployment target integration", () => { assert.notEqual(result.status, 0); assert.match(result.stderr, /health provenance did not converge/); const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); - assert.equal(evidence.outcome, "failed"); - assert.equal(evidence.failureStage, "verification"); + assertFailedEvidence(evidence, result, "verification"); assert.equal(evidence.health, null); }); + + it("records failed evidence when deployment credentials are missing", async () => { + const fixture = await deploymentFixture({ FAKE_REQUIRE_AUTH: "true" }); + delete fixture.env.CLOUDFLARE_API_TOKEN; + const result = runDeployment(fixture); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /Cloudflare API token required/, + commandFailure(result), + ); + const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); + assertFailedEvidence(evidence, result, "prior-deployment"); + assert.equal(evidence.priorDeployment, null); + }); + + it("rejects malformed prior-deployment JSON before upload", async () => { + const fixture = await deploymentFixture({ + FAKE_PRIOR_DEPLOYMENT_JSON: "{not-json", + }); + const result = runDeployment(fixture); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /prior deployment did not return a JSON object/); + const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); + assertFailedEvidence(evidence, result, "prior-deployment"); + assert.equal(evidence.priorDeployment, null); + assert.doesNotMatch(await readFile(fixture.log, "utf8"), /deploy --strict/); + }); + + it("records build failures before any control-plane access", async () => { + const fixture = await deploymentFixture({ FAKE_BUILD_FAILURE: "true" }); + const result = runDeployment(fixture); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /protocol build failed/); + const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); + assertFailedEvidence(evidence, result, "build"); + assert.equal(evidence.priorDeployment, null); + assert.doesNotMatch(await readFile(fixture.log, "utf8"), /deployments status/); + }); }); describe("manual production cutover integration", () => { it("attributes each new secret version from newest-first inventories", async () => { const fixture = await manualDeploymentFixture(); - const result = spawnSync( - "bash", - [ - MANUAL_SCRIPT, - fixture.evidence, - fixture.deviceTokens, - fixture.extensionId, - fixture.canaryCredential, - ], - { - cwd: REPO_ROOT, - env: fixture.env, - input: "DEPLOY\n", - encoding: "utf8", - }, - ); + const result = runManualDeployment(fixture); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 0, commandFailure(result)); const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); assert.equal(evidence.outcome, "verified"); assert.equal(evidence.deviceTokensSecretVersion.id, "device-secret-new"); @@ -99,6 +124,40 @@ describe("manual production cutover integration", () => { assert.match(log, /secret put DEVICE_TOKENS.*auth=present/); assert.match(log, /secret put EXTENSION_ID.*auth=present/); }); + + it("records missing production credentials before secret mutation", async () => { + const fixture = await manualDeploymentFixture({ FAKE_REQUIRE_AUTH: "true" }); + delete fixture.env.CLOUDFLARE_API_TOKEN; + const result = runManualDeployment(fixture); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /Cloudflare API token required/, + commandFailure(result), + ); + const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); + assertFailedEvidence(evidence, result, "prior-deployment"); + assert.equal(evidence.priorDeployment, null); + assert.equal(evidence.secretMutationPossible, false); + assert.doesNotMatch(await readFile(fixture.log, "utf8"), /secret put/); + }); + + it("attributes a post-secret source-ref failure", async () => { + const fixture = await manualDeploymentFixture({ + FAKE_LATE_SOURCE_FAILURE: "true", + }); + const result = runManualDeployment(fixture); + + assert.notEqual(result.status, 0); + const evidence = JSON.parse(await readFile(fixture.evidence, "utf8")); + assertFailedEvidence(evidence, result, "source-ref"); + assert.equal(evidence.secretMutationPossible, true); + const log = await readFile(fixture.log, "utf8"); + assert.match(log, /secret put DEVICE_TOKENS/); + assert.match(log, /secret put EXTENSION_ID/); + assert.doesNotMatch(log, /deploy --strict/); + }); }); async function deploymentFixture(overrides = {}) { @@ -131,7 +190,12 @@ auth=absent if [[ -n "\${CLOUDFLARE_API_TOKEN:-}" ]]; then auth=present; fi printf '%s auth=%s\\n' "$*" "$auth" >>"$FAKE_LOG" case "$*" in - "--filter @understudy/protocol build") ;; + "--filter @understudy/protocol build") + if [[ "\${FAKE_BUILD_FAILURE:-}" == "true" ]]; then + printf 'protocol build failed\n' >&2 + exit 1 + fi + ;; "--version") printf '11.5.2\\n' ;; *"wrangler deploy --dry-run"*) ;; *"wrangler deploy --strict"*) ;; @@ -139,6 +203,14 @@ case "$*" in printf '[{"id":"v1","annotations":{"workers/tag":"%s","workers/message":"source %s"}}]\\n' "\${FAKE_VERSION_TAG:-$FAKE_SHA}" "$FAKE_SHA" ;; *"wrangler deployments status --json"*) + if [[ "\${FAKE_REQUIRE_AUTH:-}" == "true" && "$auth" == "absent" ]]; then + printf 'Cloudflare API token required\n' >&2 + exit 1 + fi + if [[ -n "\${FAKE_PRIOR_DEPLOYMENT_JSON:-}" ]]; then + printf '%s\n' "$FAKE_PRIOR_DEPLOYMENT_JSON" + exit 0 + fi count_file="$FAKE_STATE/status-count" count=0 if [[ -f "$count_file" ]]; then count="$(<"$count_file")"; fi @@ -184,7 +256,7 @@ printf '{"ok":true,"commit":"%s","versionId":"v1","deployedAt":"2030-01-01T00:00 }; } -async function manualDeploymentFixture() { +async function manualDeploymentFixture(overrides = {}) { const root = await mkdtemp(join(tmpdir(), "understudy-manual-deploy-test-")); temporary.push(root); const bin = join(root, "bin"); @@ -222,7 +294,18 @@ async function manualDeploymentFixture() { set -eu case "$*" in *"rev-parse --show-toplevel"*) printf '%s\\n' "$FAKE_REPO_ROOT" ;; - *"rev-parse refs/remotes/origin/master"*) printf '%s\\n' "$FAKE_SHA" ;; + *"rev-parse refs/remotes/origin/master"*) + count_file="$FAKE_STATE/master-ref-count" + count=0 + if [[ -f "$count_file" ]]; then count="$(<"$count_file")"; fi + count=$((count + 1)) + printf '%s' "$count" >"$count_file" + if [[ "\${FAKE_LATE_SOURCE_FAILURE:-}" == "true" && "$count" -ge 3 ]]; then + printf '%s\\n' "c\${FAKE_SHA:1}" + else + printf '%s\\n' "$FAKE_SHA" + fi + ;; *"rev-parse HEAD"*) printf '%s\\n' "$FAKE_SHA" ;; *"status --porcelain"*) ;; *"fetch --quiet origin"*) ;; @@ -258,6 +341,10 @@ case "$*" in *"wrangler deploy --dry-run"*|*"wrangler deploy --strict"*) ;; *"wrangler secret put"*) IFS= read -r _ || true ;; *"wrangler deployments status --json"*) + if [[ "\${FAKE_REQUIRE_AUTH:-}" == "true" && "$auth" == "absent" ]]; then + printf 'Cloudflare API token required\n' >&2 + exit 1 + fi count_file="$FAKE_STATE/deployment-count" count=0 if [[ -f "$count_file" ]]; then count="$(<"$count_file")"; fi @@ -299,6 +386,7 @@ printf '{"ok":true,"commit":"%s","versionId":"v1","deployedAt":"2030-01-01T00:00 log, env: { ...process.env, + ...overrides, PATH: `${bin}:${process.env.PATH}`, FAKE_LOG: log, FAKE_REPO_ROOT: REPO_ROOT, @@ -318,6 +406,42 @@ function runDeployment(fixture) { }); } +function runManualDeployment(fixture) { + return spawnSync( + "bash", + [ + MANUAL_SCRIPT, + fixture.evidence, + fixture.deviceTokens, + fixture.extensionId, + fixture.canaryCredential, + ], + { + cwd: REPO_ROOT, + env: fixture.env, + input: "DEPLOY\n", + encoding: "utf8", + }, + ); +} + +function commandFailure(result) { + return JSON.stringify({ + status: result.status, + signal: result.signal, + error: result.error?.message, + stdout: result.stdout, + stderr: result.stderr, + }); +} + +function assertFailedEvidence(evidence, result, failureStage) { + assert.notEqual(result.status, 0); + assert.equal(evidence.outcome, "failed"); + assert.equal(evidence.failureStage, failureStage); + assert.equal(evidence.exitCode, result.status); +} + async function executable(path, contents) { await writeFile(path, contents); await chmod(path, 0o755); diff --git a/apps/backend/scripts/deploy-target.sh b/apps/backend/scripts/deploy-target.sh index 6f77731..bd1a250 100644 --- a/apps/backend/scripts/deploy-target.sh +++ b/apps/backend/scripts/deploy-target.sh @@ -79,18 +79,6 @@ assert_source_unchanged() { "$initial_fingerprint" "$(worktree_fingerprint)" } -cd "$repo_root" -pnpm --filter @understudy/protocol build -store_release='null' -compatibility='null' -if [[ "$MODE" == "production-auto" ]]; then - pnpm --filter @understudy/extension build:store - pnpm --filter @understudy/extension zip:store - store_release="$(pnpm --silent --filter @understudy/extension verify:store-release)" - compatibility="$(node "$backend_dir/scripts/verify-production-compatibility.mjs" live)" -fi -assert_source_unchanged - dry_run_dir="$(mktemp -d)" temporary="" cleanup() { @@ -99,28 +87,17 @@ cleanup() { } trap cleanup EXIT -understudy_deploy_init "$target" "$backend_dir" "$full_sha" "$source_tag" -understudy_deploy_dry_run "$dry_run_dir" -assert_source_unchanged - pnpm_version="$(pnpm --version)" lockfile_sha256="$(sha256sum "$repo_root/pnpm-lock.yaml" | cut -d ' ' -f 1)" -UNDERSTUDY_PRIOR_DEPLOYMENT="$(understudy_wrangler_control_plane deployments status --json)" +store_release='null' +compatibility='null' +UNDERSTUDY_PRIOR_DEPLOYMENT='null' UNDERSTUDY_HEALTH='null' UNDERSTUDY_SOURCE_RELEASE='null' UNDERSTUDY_ACTIVE_VERSION='null' UNDERSTUDY_DEPLOYMENT='null' deployment_stage="prepared" -if [[ -n "$branch" ]]; then - git -C "$repo_root" fetch --quiet origin \ - "+refs/heads/$branch:refs/remotes/origin/$branch" - remote_sha="$(git -C "$repo_root" rev-parse "refs/remotes/origin/$branch")" - node "$backend_dir/scripts/deployment-policy.mjs" current-ref \ - "$branch" "$full_sha" "$remote_sha" - assert_source_unchanged -fi - write_evidence() { local outcome="$1" local failure_stage="${2:-}" @@ -179,6 +156,13 @@ record_failed_deployment() { local exit_code="$?" trap - EXIT set +e + UNDERSTUDY_HEALTH="$(understudy_json_or_null "$UNDERSTUDY_HEALTH")" + UNDERSTUDY_SOURCE_RELEASE="$(understudy_json_or_null "$UNDERSTUDY_SOURCE_RELEASE")" + UNDERSTUDY_ACTIVE_VERSION="$(understudy_json_or_null "$UNDERSTUDY_ACTIVE_VERSION")" + UNDERSTUDY_DEPLOYMENT="$(understudy_json_or_null "$UNDERSTUDY_DEPLOYMENT")" + UNDERSTUDY_PRIOR_DEPLOYMENT="$(understudy_json_or_null "$UNDERSTUDY_PRIOR_DEPLOYMENT")" + store_release="$(understudy_json_or_null "$store_release")" + compatibility="$(understudy_json_or_null "$compatibility")" write_evidence "failed" "$deployment_stage" "$exit_code" cleanup exit "$exit_code" @@ -186,6 +170,36 @@ record_failed_deployment() { write_evidence "attempting" trap record_failed_deployment EXIT +deployment_stage="build" +cd "$repo_root" +pnpm --filter @understudy/protocol build +if [[ "$MODE" == "production-auto" ]]; then + pnpm --filter @understudy/extension build:store + pnpm --filter @understudy/extension zip:store + store_release="$(pnpm --silent --filter @understudy/extension verify:store-release)" + compatibility="$(node "$backend_dir/scripts/verify-production-compatibility.mjs" live)" +fi +assert_source_unchanged + +understudy_deploy_init "$target" "$backend_dir" "$full_sha" "$source_tag" +deployment_stage="dry-run" +understudy_deploy_dry_run "$dry_run_dir" +assert_source_unchanged + +deployment_stage="prior-deployment" +UNDERSTUDY_PRIOR_DEPLOYMENT="$(understudy_wrangler_control_plane deployments status --json)" +understudy_require_json_type "$UNDERSTUDY_PRIOR_DEPLOYMENT" object "prior deployment" + +if [[ -n "$branch" ]]; then + deployment_stage="source-ref" + git -C "$repo_root" fetch --quiet origin \ + "+refs/heads/$branch:refs/remotes/origin/$branch" + remote_sha="$(git -C "$repo_root" rev-parse "refs/remotes/origin/$branch")" + node "$backend_dir/scripts/deployment-policy.mjs" current-ref \ + "$branch" "$full_sha" "$remote_sha" + assert_source_unchanged +fi + deployment_stage="upload" understudy_deploy_release deployment_stage="verification" diff --git a/docs/network-blip-rollout-handoff.md b/docs/network-blip-rollout-handoff.md index d71e147..f6176e1 100644 --- a/docs/network-blip-rollout-handoff.md +++ b/docs/network-blip-rollout-handoff.md @@ -4,7 +4,7 @@ ## Current state -This record applies to integration branch `feat/semantic-elements`, compared with `origin/dev` at `857e0e3ebb8312be3e260e7339968f602703afdf`. The semantic tranche starts after `554a09c53dd4a9b64755da38d0260d4da37fa3d2`. Production still serves the pre-change Worker until an operator runs the gated release process. +This record applies to the protocol-3 stack merged into `dev` by PR #24 at `a35b8221111df6757ec6d87745cd7110e804536e`. Its historical comparison base is `origin/dev` at `857e0e3ebb8312be3e260e7339968f602703afdf`, and the semantic tranche starts after `554a09c53dd4a9b64755da38d0260d4da37fa3d2`. The first automatic staging deployment did not upload because its GitHub environment lacked `CLOUDFLARE_API_TOKEN`; production still serves the pre-change Worker until an operator completes the gated release process. Historical sanitized observations: diff --git a/docs/semantic-elements-handoff.md b/docs/semantic-elements-handoff.md index 10e16fa..b72db0a 100644 --- a/docs/semantic-elements-handoff.md +++ b/docs/semantic-elements-handoff.md @@ -20,16 +20,17 @@ selector cache, action self-healing, or post-capture full-tree filtering. | Field | Value | | --- | --- | -| Implementation branch | `feat/semantic-elements` | +| Implementation status | PR #24 merged into `dev` at `a35b8221111df6757ec6d87745cd7110e804536e` | +| Feature head | `414aa35d30115e5e157d81e1abe3add88dfe9e21` | | Branch point | `554a09c53dd4a9b64755da38d0260d4da37fa3d2` (`feat: add guarded deployment workflow`) | | Fetched `origin/dev` at implementation start | `857e0e3ebb8312be3e260e7339968f602703afdf` | | Node.js | 24.16.0; packages require 22 or newer | | pnpm | 11.5.2 | | Release train | Protocol 0.9.0, connector 0.6.0, extension 0.2.0, backend 0.2.0 | -The local `dev` branch was clean and three commits ahead of the fetched -`origin/dev` when this branch was created. Do not rebase this work onto the -older remote ref without reviewing those local commits. +The feature branch started from a local `dev` that was three commits ahead of +the fetched `origin/dev`; PR #24 preserved that full stack. The older remote +ref is historical context, not a valid release base. `pnpm test` and the backend deployment integration suite require permission to bind loopback ports. The extension release integration test creates temporary @@ -247,7 +248,7 @@ the exact source tree on which it ran. | Gate | Result | | --- | --- | | Protocol focused tests | Passed: 52 tests | -| Backend suite | Passed: 383 Vitest and 4 deployment integration tests | +| Backend suite | Passed: 383 Vitest and 9 deployment integration tests | | Extension focused semantic/event tests | Passed: 87 tests in the final focused run | | Extension full suite | Passed: 307 Vitest and 3 release integration tests | | Root typecheck/build/test | Passed; connector 29 tests also passed | @@ -275,7 +276,7 @@ pnpm exec wrangler types --check pnpm exec wrangler deploy --dry-run ``` -All commands above pass on the implementation tree. The implementation tranche is committed; publication still requires the Changesets version pull request and the `dev` to `master` promotion described in the release runbook. +All commands above pass on the implementation tree. PR #24 merged the implementation into `dev`; publication still requires merging Changesets release PR #25 and completing the `dev` to `master` promotion described in the release runbook. ## Out of scope diff --git a/docs/unattended-production-rollout.md b/docs/unattended-production-rollout.md index ef4ec19..b45f5de 100644 --- a/docs/unattended-production-rollout.md +++ b/docs/unattended-production-rollout.md @@ -2,7 +2,7 @@ # Finish the protocol-3 production rollout -This runbook defines the release sequence after the local protocol-3 implementation. The source and automated checks are complete. Network disruption tests, publishing, production changes, destructive cleanup, canary acceptance, and the HTTPS Strict Transport Security (HSTS) apex rollout still require operator action. +This runbook defines the release sequence after PR #24 merged the protocol-3 implementation into `dev`. Source integration and the local automated checks are complete. The first automatic staging deployment stopped before upload because the `staging` GitHub environment had no `CLOUDFLARE_API_TOKEN`; no staging Worker change occurred. Staging credentials and provisioning, network disruption tests, publishing, production changes, destructive cleanup, canary acceptance, and the HTTPS Strict Transport Security (HSTS) apex rollout still require operator action. ## Handoff baseline @@ -10,16 +10,18 @@ Use this table to establish the source and environment before you act: | Field | Recorded value | | --- | --- | -| Integration branch | `feat/semantic-elements` | -| Comparison base | `origin/dev` at `857e0e3ebb8312be3e260e7339968f602703afdf` | +| Integration branch | `dev` | +| Integration pull request | PR #24, merged 2026-08-04 | +| Feature head | `414aa35d30115e5e157d81e1abe3add88dfe9e21` | +| Integration merge | `a35b8221111df6757ec6d87745cd7110e804536e` | +| Historical comparison base | `origin/dev` at `857e0e3ebb8312be3e260e7339968f602703afdf` | | Semantic tranche base | `554a09c53dd4a9b64755da38d0260d4da37fa3d2` (`feat: add guarded deployment workflow`) | -| Fetched `origin/dev` on 2026-08-04 | `857e0e3ebb8312be3e260e7339968f602703afdf` | -| Release source | The commit containing this handoff; resolve it with `git rev-parse HEAD` | +| Release source | Current `origin/dev`; refresh it and resolve with `git rev-parse origin/dev` before acting | | Node.js | 24.16.0; repository minimum is 22 | | pnpm | 11.5.2; the root manifest pins this version | | Production state last inspected | 2026-08-04 for HTTP/HTTPS health; DNS inventory remains from 2026-08-02; refresh every external value before acting | -The integration branch intentionally includes the local card-vault requirements, protocol-3/card-vault implementation, guarded deployment workflow, and semantic-elements tranche. Do not split or rebase this stack onto the older remote ref without re-running the compatibility review. Do not deploy a dirty tree or a later unreviewed commit. +The merged stack includes the local card-vault requirements, protocol-3/card-vault implementation, guarded deployment workflow, and semantic-elements tranche. Do not split or rebase this stack onto the historical remote ref without re-running the compatibility review. Do not deploy a dirty tree or a later unreviewed commit. ## Implemented release @@ -42,13 +44,15 @@ These results apply to the source code in this branch. A code or configuration c | Gate | Evidence | State | | --- | --- | --- | -| Unit and integration tests | 2026-08-04: 778 tests; protocol 52, connector 29, extension 307 plus 3 release integration tests, backend 383 plus 4 deployment integration tests | Passed | +| Unit and integration tests | 2026-08-04: 783 tests; protocol 52, connector 29, extension 307 plus 3 release integration tests, backend 383 plus 9 deployment integration tests | Passed | | Typecheck and build | `pnpm typecheck` and `pnpm build` after all review fixes | Passed | | Dependency audit | 2026-08-04: 0 critical/high; 2 moderate and 2 low transitive advisories remain | Passed release threshold | | Local Chrome end-to-end | Vault restart, Chrome DevTools Protocol submission, worker-eviction recovery, deletion, and synthetic-marker non-egress | Passed | | Store package | Pre-workflow `0.2.0` artifact SHA-256 `3b492a1608131088c607e5254317708165e8785c67ee73c393c40578fff9b54f` | Superseded; rebuild after the deployment changes | | Wrangler | Generated types current; deployment dry run completed without upload | Passed | | Review lanes | Independent clean-code, architecture, and quality-assurance reviews returned clean after fixes | Passed | +| Integration workflows | PR #24 and post-merge CI passed; Version run `30883763357` opened release PR #25 | Passed | +| Automatic staging deployment | Run `30883763461` passed install, build, typecheck, tests, Worker types, extension verification, and dry run, then failed before upload because the `staging` environment had no `CLOUDFLARE_API_TOKEN` | Blocked; no staging mutation | | Network baseline | Test A 10s and 30s passed; Test A 60s and 120s plus Test B 30s remain | Partial | | Production HSTS | 2026-08-04 probes found no HTTP upgrade and no HTTPS HSTS header; the 2026-08-02 DNS inventory found no apex address record | Pending | | Cloud-vault cleanup | No values, namespace, or production secrets were deleted | Pending | @@ -63,7 +67,7 @@ Complete the remaining work in this order: | ---: | --- | --- | --- | | 1 | Run the remaining pre-fix network baseline | No active soak; caller credential; exact device and origin; approval immediately before firewall or Tailscale mutation | Mode-0600 JSONL evidence outside Git | | 2 | Apply the deterministic reconnect result | Baseline evidence from step 1 | Selected branch recorded; any required code change reviewed and verified | -| 3 | Confirm the integration pull request is merged into `dev`, then verify the automatic staging deployment | Repository and staging GitHub-environment authority | Scoped Cloudflare token present, staging secrets provisioned, pull request, merge SHA, and automatic staging deployment evidence | +| 3 | Add the missing staging-scoped `CLOUDFLARE_API_TOKEN`, provision staging, and rerun the automatic deployment; PR #24 is already merged | Staging GitHub-environment and Cloudflare authority | Scoped token present, staging secrets provisioned, merge SHA, and verified automatic deployment evidence | | 4 | Verify staging with the pinned staging extension | Staging account and dedicated Chrome profile | Pairing, OAuth/MCP, and hosted-control evidence | | 5 | Publish protocol 0.9.0 and connector 0.6.0 | Package-registry release authority | Registry versions and package integrity values | | 6 | Build, submit, and publish extension 0.2.0 | Chrome Web Store authority | Submitted ZIP, normalized content digest, published status, and source SHA in `store-release.json` | @@ -80,7 +84,7 @@ Complete the remaining work in this order: Do not combine steps 8 and 11 into one unobserved change. First prove that the compatibility deployment is healthy. Then start the separately confirmed hard-cut window. -If the environments are not already configured, create the GitHub `staging` environment, restrict it to `dev`, and add a `CLOUDFLARE_API_TOKEN` secret scoped only to the staging Worker and its required resources before relying on the staging deployment. Provision the six staging Worker secrets with the backend command documented below. Create the `production` environment separately, restrict it to `master`, add a production-scoped token, and leave `PRODUCTION_AUTODEPLOY_ENABLED=false` until step 9. Never reuse either deployment token or any Worker runtime secret across targets. +The GitHub `staging` environment exists, but its environment-secret inventory was empty when checked on 2026-08-04. Restrict it to `dev`, add a `CLOUDFLARE_API_TOKEN` secret scoped only to the staging Worker and its required resources, then provision the six staging Worker secrets with the backend command documented below. The separate `production` environment also exists with an empty secret inventory; restrict it to `master`, add a production-scoped token before deployment, and leave `PRODUCTION_AUTODEPLOY_ENABLED=false` until step 9. Never reuse either deployment token or any Worker runtime secret across targets. ## Run the remaining network baseline