From 96ed0949ca416cf88c0c2b176a0fe1d1ac8fe5fe Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 31 Oct 2026 00:58:56 +0300 Subject: [PATCH] Clarify production handoff is not deployed --- .gitea/workflows/deploy-handoff.yaml | 11 ++++++-- docs/operations/CURRENT_STATE.md | 14 +++++++--- docs/operations/DEPLOYMENT.md | 32 ++++++++++++++++++++++ docs/operations/DEPLOYMENT_OWNER_PACKET.md | 12 ++++++++ infra/deploy/test_release_workflow.py | 6 ++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/deploy-handoff.yaml b/.gitea/workflows/deploy-handoff.yaml index e7eb015..b187581 100644 --- a/.gitea/workflows/deploy-handoff.yaml +++ b/.gitea/workflows/deploy-handoff.yaml @@ -1,4 +1,4 @@ -name: Prepare production deploy handoff +name: Production handoff status (not deployed) on: workflow_run: @@ -7,7 +7,8 @@ on: types: [completed] jobs: - validate-handoff: + deployment-status: + name: NOT DEPLOYED - handoff status only if: >- gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'workflow_run' && @@ -55,3 +56,9 @@ jobs: trap 'rm -f "$metadata"' EXIT gitea-api GET "/repos/admin/akyldash/actions/runs/${SOURCE_RUN_ID}/artifacts" > "$metadata" python3 infra/deploy/release_handoff.py dry-run --artifact-metadata "$metadata" + - name: State that no deployment was performed + run: | + echo 'DEPLOYMENT STATUS: NOT DEPLOYED' + echo 'This workflow checks source provenance and may inspect artifact metadata only.' + echo 'It does not download the descriptor ZIP, open SSH, or invoke the Windows receiver.' + echo 'No Windows execution runner or automatic runtime binding has been verified.' diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index f923ada..783c4da 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -214,10 +214,16 @@ Docker state, UFW rule details, and protected file metadata were not verified. The descriptor remains unsigned trace metadata, not cryptographic attestation. - A separate `deploy-handoff.yaml` runs after a successful release and checks - the source run and stale-main guards. It is dry-run only. It reads artifact - metadata through `gitea-api` only if that helper is available in the job - image; otherwise it reports metadata and host handoff as unverified. It does - not download artifact ZIPs, connect to SSH, or apply a release. The original + the source run and stale-main guards. Its job is explicitly + `NOT DEPLOYED - handoff status only`; a green result is not a deployment. It + reads artifact metadata through `gitea-api` only if that helper is available + in the job image; otherwise it reports metadata and host handoff as + unverified. It does not download artifact ZIPs, connect to SSH, or apply a + release. No repository workflow binding to a Windows runtime starts the receiver; + external Windows runner/service inventory has not been verified. The client + currently requires an explicit process running `receive_external.py` with + the release tuple to open the SSH session. Image release is automated, but + production deployment is not yet automatic CD. The original audited `gitea-api` helper lacks binary output and safe 302 handling; the current PR carries a tested patch and host-side artifact verifier, but neither is installed. No deploy key, fixed SSH entry, extra sudo rule, or diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index ed92d63..1f88613 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -35,6 +35,38 @@ the response, and refuses to overwrite an existing file. The installer checks the exact audited helper fixture hash before applying its patch. The helper and source code are not installed on the VPS by this PR. +## Automation boundary: deployment is not configured + +The automatic chain currently ends at release publication: + +1. Successful `main` CI triggers image builds and publishes digest references + plus the release descriptor artifact. +2. `deploy-handoff.yaml` verifies the source run and may read artifact metadata. + Its job is explicitly status-only: **NOT DEPLOYED**. It does not download + the descriptor ZIP, call the Windows client, open SSH, or invoke the root + receiver. A green result means only that this status check completed. +3. The fixed receiver is physically started only when a Windows process runs + `infra/backup/receive_external.py` with the release run ID, commit, and + descriptor digest. That command opens the owner's SSH session to the fixed + root receiver. No repository workflow binding starts it; external Windows + runner/service inventory has not been verified. + +Therefore production CD is not yet automatic. A runner label only routes a +job; it does not prevent a pull-request workflow from requesting that label. +Before unattended cutover, the owner must choose an execution design that +demonstrably prevents untrusted PR jobs from reaching the Windows host. A +dedicated persistent host-mode runner with D: data and SSH access is unsafe +while general repository PR workflows can target it. Acceptable designs +include an isolated one-job executor exposed only after an external controller +validates the trusted release event and exact run/commit/artifact tuple, or a +separate protected deployment repository with no untrusted PR trigger/write +path. In either design, the fixed receiver must report its ACK and SSH result +before deployment is marked successful. Gitea 1.25 documents Windows runner +installation and service operation in its +[act_runner guide](https://docs.gitea.com/1.25/usage/actions/act_runner). +No such isolation/controller, Windows runner, registration, token, service, +SSH credential, or workflow binding has been verified or installed by this PR. + ## External backup and cutover The fixed root release receiver accepts only a bounded protocol request over diff --git a/docs/operations/DEPLOYMENT_OWNER_PACKET.md b/docs/operations/DEPLOYMENT_OWNER_PACKET.md index 4ee155b..c91ecc0 100644 --- a/docs/operations/DEPLOYMENT_OWNER_PACKET.md +++ b/docs/operations/DEPLOYMENT_OWNER_PACKET.md @@ -6,6 +6,18 @@ private keys, secret material, or raw process environments. ## Required before release use +- Decide whether unattended production CD is acceptable. It currently is not + configured: `deploy-handoff.yaml` is metadata/status-only and reports + `NOT DEPLOYED`; the Windows receiver starts only from an explicit owner-run + CLI command. Automatic activation requires a secure Windows execution path + available only to a validated trusted release. A `runs-on` label is routing, + not authorization; a persistent host-mode runner sharing D: and SSH files + must not be exposed to general repository PR workflows. Before enabling CD, + choose an isolated one-job executor gated by an external validator, or a + separately protected deployment repository with no untrusted PR trigger or + write path. Verify that it validates repository, workflow, event, branch, + run, commit, and artifact digest, then reports receiver ACK and SSH success. + No such runtime isolation or binding exists in this PR. - Confirm the registry package names and least-privilege writer/pull access for `admin/akyldash/backend` and `admin/akyldash/opensearch`; record digest retention for the rollback window. diff --git a/infra/deploy/test_release_workflow.py b/infra/deploy/test_release_workflow.py index 7bdda55..a626c57 100644 --- a/infra/deploy/test_release_workflow.py +++ b/infra/deploy/test_release_workflow.py @@ -121,6 +121,8 @@ class ReleaseWorkflowTests(unittest.TestCase): def test_deploy_handoff_is_dry_and_uses_the_source_release_run_id(self): workflow = (ROOT / ".gitea/workflows/deploy-handoff.yaml").read_text(encoding="utf-8") + self.assertIn("name: Production handoff status (not deployed)", workflow) + self.assertIn("name: NOT DEPLOYED - handoff status only", workflow) self.assertIn('workflows: ["Release production images"]', workflow) self.assertIn("branches: [main]", workflow) self.assertIn("types: [completed]", workflow) @@ -134,6 +136,10 @@ class ReleaseWorkflowTests(unittest.TestCase): self.assertNotIn("AKYLDASH_DEPLOY_SSH_KEY", workflow) self.assertNotIn("docker.sock", workflow) self.assertIn("host handoff are not verified", workflow) + self.assertIn("DEPLOYMENT STATUS: NOT DEPLOYED", workflow) + self.assertIn("No Windows execution runner or automatic runtime binding has been verified.", workflow) + self.assertNotIn("receive_external.py", workflow) + self.assertNotIn("akyldash-production-windows", workflow) def test_source_guards_reject_wrong_event_or_stale_main(self): valid = dict(