ci: chain release and dry-run handoff
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s

This commit is contained in:
agent committed 2026-10-26 22:43:11 +03:00
1 parent f1573edfa7
commit 74aa167d16
9 files changed
+457 -57

No files matched your search

+1
View File
@@ -55,6 +55,7 @@ jobs:
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/validate_release.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deploy_release.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/create_release_manifest.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_handoff.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_refs.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_state.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deployment_lock.py').read_text())"
+53
View File
@@ -0,0 +1,53 @@
name: Prepare production deploy handoff
on:
workflow_run:
workflows: ["Release production images"]
branches: [main]
types: [completed]
jobs:
validate-handoff:
if: >-
gitea.event.workflow_run.conclusion == 'success' &&
gitea.event.workflow_run.event == 'workflow_run' &&
gitea.event.workflow_run.head_branch == 'main'
runs-on: akyldash-ci
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Verify source release identity before artifact lookup
env:
SOURCE_WORKFLOW: ${{ gitea.event.workflow_run.name }}
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
run: python3 infra/deploy/release_handoff.py validate-handoff-source
- name: Read artifact metadata with the installed Gitea helper when available
env:
GITEA_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ gitea.token }}
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
SOURCE_WORKFLOW: ${{ gitea.event.workflow_run.name }}
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
run: |
if ! command -v gitea-api >/dev/null 2>&1; then
echo 'DRY-RUN only: gitea-api is unavailable in this job; artifact metadata and host handoff are not verified.'
python3 infra/deploy/release_handoff.py dry-run
exit 0
fi
if [[ -z ${GITEA_TOKEN:-} ]]; then
echo 'DRY-RUN only: GITEA_TOKEN is unavailable; artifact metadata and host handoff are not verified.'
python3 infra/deploy/release_handoff.py dry-run
exit 0
fi
metadata=$(mktemp)
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"
+30 -7
View File
@@ -1,14 +1,30 @@
name: Release production images
on:
workflow_dispatch:
workflow_run:
workflows: ["CI"]
branches: [main]
types: [completed]
jobs:
publish-images:
if: gitea.ref == 'refs/heads/main'
if: >-
gitea.event.workflow_run.conclusion == 'success' &&
gitea.event.workflow_run.event == 'push' &&
gitea.event.workflow_run.head_branch == 'main'
runs-on: akyldash-ci
steps:
- uses: actions/checkout@v4
with:
ref: ${{ gitea.event.workflow_run.head_sha }}
- name: Verify successful CI source before using registry credentials
env:
SOURCE_WORKFLOW: ${{ gitea.event.workflow_run.name }}
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
run: python3 infra/deploy/release_handoff.py validate-ci-source
- name: Require explicitly configured Gitea registry credentials
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
@@ -24,7 +40,7 @@ jobs:
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.backend
destination: git.vakanaut.ru/admin/akyldash/backend:${{ github.sha }}-${{ github.run_id }}
destination: git.vakanaut.ru/admin/akyldash/backend:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
digest_file: ${{ github.workspace }}/release/backend.digest
- name: Build and publish OpenSearch image
uses: ./.gitea/actions/kaniko-push
@@ -34,24 +50,31 @@ jobs:
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.opensearch
destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ github.sha }}-${{ github.run_id }}
destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
digest_file: ${{ github.workspace }}/release/opensearch.digest
- name: Create and validate release descriptor
run: |
python3 infra/deploy/create_release_manifest.py \
--repository admin/akyldash \
--commit '${{ github.sha }}' \
--run-id '${{ github.run_id }}' \
--commit '${{ gitea.event.workflow_run.head_sha }}' \
--run-id '${{ gitea.run_id }}' \
--backend-digest release/backend.digest \
--opensearch-digest release/opensearch.digest \
--output release/release-manifest.json
python3 infra/deploy/validate_release.py release/release-manifest.json \
--policy infra/deploy/release-policy.json
(cd release && sha256sum release-manifest.json > release-manifest.json.sha256)
- id: descriptor
name: Record release descriptor digest
run: |
(cd release && sha256sum -c release-manifest.json.sha256)
digest=$(sha256sum release/release-manifest.json)
digest=${digest%% *}
printf 'descriptor_sha256=%s\n' "$digest" >> "$GITEA_OUTPUT"
- name: Upload verified release descriptor
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
with:
name: release-descriptor-${{ github.run_id }}
name: release-descriptor-${{ gitea.run_id }}-${{ steps.descriptor.outputs.descriptor_sha256 }}
path: |
release/release-manifest.json
release/release-manifest.json.sha256
+33 -27
View File
@@ -71,35 +71,41 @@ listed in the [official GitHub Packages entry](https://github.com/osscontainerto
The pinned Caddy image digest remains the same as the former `docker run`
validation. act_runner starts these container actions; they do not expose its
rootless Docker socket to workflow steps.
Production deployment is not automated.
Production deployment is not enabled. `release-images.yaml` now starts only
after a successful `CI` workflow run for a push to `main`; it checks the source
workflow, event, branch, conclusion, and SHA and checks that the SHA is still
the current `main` before exposing Registry credentials. It checks out that
exact source SHA and uses the release workflow's own run ID for image tags and
the schema-v3 descriptor. PR CI remains no-push and receives no Registry or
production credentials.
`release-images.yaml` is a separate manual workflow allowed only on protected
`main`. It uses the same pinned daemonless Kaniko builder sequentially and
disables Kaniko layer-cache pushes, so it adds no Docker daemon, socket, VM, or
concurrent runner slot. Its temporary peak RAM/CPU/disk use has not been
measured separately from the already successful sequential image builds; the
configured 2 CPU, 3 GiB RAM, 512-process per-job and 15 GiB storage limits still
apply. The workflow fails before building if `REGISTRY_USERNAME` or
`REGISTRY_PASSWORD` is absent. Those names are not configured or verified on
Gitea. If the owner later enables it, the credential must be package-write-only
for the two approved image names; no production env, service secret, runner
socket, or host mount is passed. The release descriptor is unsigned and is
uploaded with its SHA-256 sidecar as a Gitea Actions artifact using the
full-SHA-pinned Gitea-compatible upload-artifact v3 action. Gitea 1.25.5
provides the artifact runtime endpoints and act_runner 0.2.13 exposes
`ACTIONS_RUNTIME_URL` independently of the cache URL. PR CI uploads a small
synthetic marker with one-day retention to verify that endpoint without
registry credentials. Runner cache remains disabled and unchanged. The release
artifact requests 90-day retention, subject to the instance's configured cap;
the owner must confirm retention covers the rollback window. The workflow has
not yet been run with registry credentials. The sidecar is generated from the
`release` directory using the manifest basename, so the downloaded artifact can
be verified from its extraction root with
`sha256sum -c release-manifest.json.sha256`.
Image publication still requires the existing `REGISTRY_USERNAME` and
`REGISTRY_PASSWORD` Actions secrets, which are not configured or verified.
When configured, they must be package-write-only for the two approved image
names. The release descriptor is unsigned. The SHA-256 is included in the
artifact name so a later workflow can identify the expected digest from
artifact metadata without treating job output as trusted input. The existing
SHA-256 sidecar remains inside the artifact. The workflow has not yet run with
Registry credentials, and the sequential Kaniko build's peak resources have not
been measured beyond the configured 2 CPU, 3 GiB RAM, 512-process job limits.
The observed Gitea 1.25.5 run API returns `run_attempt: 0` (the legacy sentinel),
so release image tags, artifact names, and schema v3 descriptors use the unique
run ID only. Both artifact uploads fail if their expected files are missing.
`deploy-handoff.yaml` runs after a successful release on `main`. It rechecks the
source run and stale-SHA guard. Its current mode is explicitly dry-run: it may
read JSON artifact metadata using the installed `gitea-api` helper when present,
but it never downloads the ZIP or opens an SSH connection. If the helper is not
present in the job image, it reports that artifact metadata and host handoff
remain unverified. No SSH, sudo, production secret, or Docker socket is passed
to the runner.
Gitea 1.25.5 documents a run-artifact metadata endpoint and a ZIP endpoint that
redirects to a blob URL. The currently audited `gitea-api` helper supports JSON
GET requests but has no binary output option and does not follow redirects, so
it cannot yet fetch and independently verify the host-side artifact. Do not
enable a live handoff until an owner-approved binary-safe retrieval capability
validates HTTPS redirects and never forwards the API token across origins. The
host must independently validate the successful `main` release run, artifact
name and ZIP sidecar before any guarded deployment. The owner must also verify
artifact and Registry retention covers the rollback window.
## Installation and registration
+21 -8
View File
@@ -152,7 +152,7 @@ Docker state, UFW rule details, and protected file metadata were not verified.
`status` helper, guarded rollback helper, and installer with exact sudoers
command rules. None is installed on the VPS. This branch adds an owner-run
release cutover helper; it is deliberately not added to the `codex` sudoers
allowlist. CI contains no production deployment job; service changes remain
allowlist. CI contains no production apply job; service changes remain
owner-run.
- Digest rollback now requires release state to pin current and previous
refs/image IDs and the selected verified backup to match that previous state.
@@ -160,13 +160,26 @@ Docker state, UFW rule details, and protected file metadata were not verified.
`--no-build`, and preserves a secret-free recovery journal on failure. Legacy
tag rollback remains supported. These paths have synthetic tests only; they
have not been exercised on the VPS.
- The manual image release workflow is restricted to workflow dispatch from
protected `main`; PR image builds remain no-push. It requires owner-configured
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD`, validates published digests
against an explicit repository allowlist, and uploads a schema-v3
commit/run-ID descriptor with SHA-256 as an Actions artifact. The descriptor
is unsigned trace metadata, not cryptographic attestation. Registry write
rights and digest retention are not configured or verified.
- The image release workflow now starts after successful CI for a push to
`main`. It checks the source workflow/event/branch/conclusion and exact source
SHA, checks out that SHA, and uses the release run ID for tags and the
schema-v3 descriptor. Registry secrets `REGISTRY_USERNAME` and
`REGISTRY_PASSWORD` remain unconfigured/unverified; no values were created or
inspected. PR image builds remain no-push. The descriptor SHA-256 is included
in the artifact name, and the artifact also retains its checksum sidecar.
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 audited
`gitea-api` helper lacks binary output and safe 302 handling, so the host
cannot yet independently retrieve and validate the descriptor. No deploy
key, fixed receiver, extra sudo rule, or Gitea CD variable has been
configured. Production apply remains blocked on a reviewed binary-safe
artifact reader and a fixed root-owned forced command; backup/data/rollback
gates still apply before any actual cutover.
- A repository-scoped official `act_runner` `0.2.13` installation is
configured in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB
ext4 loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU,
+52 -7
View File
@@ -102,23 +102,68 @@ not use the synthetic `registry.invalid` names in tests as production values.
The host integration must load policy only from its fixed root-owned path.
This validator checks format and allowlist only: a manifest can claim a main
ref and commit, but is not proof of source, build provenance, signature, or
trusted publication. CI still has no publish credentials, image publication,
host helper, or deploy trigger.
trusted publication. The release workflow now follows successful main CI and
publishes only when the existing Registry secrets are configured.
Compose merges an image override with the existing service `build` entries.
The regression test renders fake digest references and confirms both entries
remain; any future deploy command must use `docker compose up --no-build` so
the host cannot silently build a different image. This does not make digest
rollback available: the current rollback helper expects local tags. A separate
host-side deploy/rollback design, owner-approved registry permissions and
retention, and manual backup/health/rollback end-to-end validation are still
required before publishing or enabling CD.
rollback available: the current rollback helper expects local tags. Owner-
approved registry permissions and retention, plus manual backup/health/rollback
end-to-end validation are still required before a real production cutover.
## Automated release and dry-run handoff
`release-images.yaml` starts after a successful `CI` run from a push to `main`.
It verifies the upstream event, source branch, conclusion, and SHA, checks that
the source is still current `main`, and builds that exact checkout. PR image
builds remain no-push. The workflow does not receive production runtime
secrets.
`deploy-handoff.yaml` starts after a successful release workflow and repeats
the source-run and stale-SHA checks. It is dry-run only: it may read artifact
metadata through `gitea-api` if that helper is installed in the job image, but
does not download the artifact ZIP, connect to the host, or apply a release.
When the helper is absent it reports that descriptor metadata and host handoff
are not verified. The checked-in `gitea-api` helper supports JSON GET responses
but does not provide binary output or follow the 302 redirect used by Gitea's
artifact ZIP endpoint. Do not configure a deploy key or enable a live handoff
until the host can independently fetch and validate the successful source
artifact through an approved binary-safe capability.
For Gitea 1.25.5, metadata is read from
`GET /repos/admin/akyldash/actions/runs/{source_run_id}/artifacts`; the host
must then download
`GET /repos/admin/akyldash/actions/artifacts/{artifact_id}/zip`. The latter
redirects to a blob URL. Any future fetcher must bound response size, require
HTTPS redirects, validate the redirected destination, and never send the
`GITEA_TOKEN` to a different origin. The existing helper's accepted
`METHOD /path [JSON]` interface does not provide those binary-download
guarantees.
The future transport contract is a dedicated SSH key whose `authorized_keys`
entry forces `sudo -n /usr/local/sbin/akyldash-release-deploy` with OpenSSH
`restrict` enabled. It accepts only `run_id`, source `commit`, and
`descriptor_sha256`; the host must fetch the named artifact itself, verify its
run, source branch/conclusion, manifest and checksum, then use the existing
root-owned policy and guarded deploy helper. No remote command or artifact path
is accepted from the job. This receiver, key, host pin, and command permission
are not configured or installed. Future repository settings are named
`AKYLDASH_DEPLOY_SSH_KEY` and `AKYLDASH_DEPLOY_KNOWN_HOSTS` (Actions secrets)
and `AKYLDASH_CD_ENABLED=true` (repository variable); do not create them until
the receiver and artifact fetcher are implemented and reviewed. Backup,
corpus/data, canary and rollback gates remain required before an actual cutover.
## CI
`.gitea/workflows/ci.yaml` runs the Python unit suites and builds both
production images on pull requests and pushes to `main`. It does not publish an
image or deploy production. `.gitea/workflows/runner-smoke.yaml` exercises the
image or deploy production. After successful main CI, the separate
`release-images.yaml` workflow is scheduled to publish digest-pinned images;
`deploy-handoff.yaml` validates a dry-run handoff. Neither workflow has been
run with release credentials, and neither applies a production release.
`.gitea/workflows/runner-smoke.yaml` exercises the
`akyldash-ci` runner on pull requests, pushes to `main`, or manual dispatch.
Both workflows passed on main head `0e276ae6` in runs 75 and 76 respectively.
The repository runner-list endpoint is not available to the current API
@@ -21,6 +21,24 @@ or raw process environments.
instance policy may cap that value. PR CI uploads a tiny marker for one day
to validate the runner artifact endpoint. Neither requires enabling the
runner cache service.
- The release workflow is now triggered by a successful `CI` workflow run for
a `main` push. The subsequent `deploy-handoff.yaml` is dry-run only and opens
no host connection. Do not create `AKYLDASH_DEPLOY_SSH_KEY`,
`AKYLDASH_DEPLOY_KNOWN_HOSTS`, or an `AKYLDASH_CD_ENABLED` variable yet; the
host receiver and binary-safe artifact retrieval are not implemented or
installed.
- The future host command contract is limited to the fixed forced command
`sudo -n /usr/local/sbin/akyldash-release-deploy`, with `restrict` in the
dedicated key's `authorized_keys` entry. The receiver must accept only
`run_id`, source commit, and descriptor SHA-256, independently retrieve and
validate the successful main-run artifact, then invoke the existing guarded
deploy path. It must not accept a remote command or caller-supplied file
path. No key, sudo rule, SSH change, or host command has been installed.
- The currently audited `gitea-api` helper can return JSON metadata but cannot
save a binary response or safely follow the Gitea artifact ZIP redirect. A
future owner-approved artifact reader must validate HTTPS redirects and must
not forward `GITEA_TOKEN` to another origin. The workflow will remain
dry-run-only until this capability and the fixed host receiver are reviewed.
- The release descriptor is unsigned. Decide whether Gitea run/artifact
traceability is sufficient for the first owner-run release or require a
separately provisioned signing/attestation service before publishing.
+122
View File
@@ -0,0 +1,122 @@
"""Fail-closed validation for CI-to-release and release-to-deploy workflow events."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
RUN_ID_RE = re.compile(r"[0-9]+\Z")
DESCRIPTOR_RE = re.compile(r"release-descriptor-([0-9]+)-([0-9a-f]{64})\Z")
def latest_main_sha() -> str:
result = subprocess.run(
["git", "ls-remote", "--exit-code", "origin", "refs/heads/main"],
check=True,
text=True,
capture_output=True,
)
fields = result.stdout.split()
if len(fields) != 2 or fields[1] != "refs/heads/main" or not SHA_RE.fullmatch(fields[0]):
raise ValueError("could not determine the current main commit")
return fields[0]
def validate_source(
*, workflow: str, event: str, conclusion: str, branch: str, run_id: str, head_sha: str,
stage: str, current_main: str,
) -> None:
expected = {
"release": ("CI", "push"),
"handoff": ("Release production images", "workflow_run"),
}
if stage not in expected:
raise ValueError("unknown release handoff stage")
expected_workflow, expected_event = expected[stage]
if workflow != expected_workflow or event != expected_event:
raise ValueError("source workflow or event is not approved for this stage")
if conclusion != "success" or branch != "main":
raise ValueError("only successful source runs on main may continue")
if not RUN_ID_RE.fullmatch(run_id) or not SHA_RE.fullmatch(head_sha):
raise ValueError("source run ID or commit SHA is invalid")
if current_main != head_sha:
raise ValueError("source run is stale because main has advanced")
def descriptor_request(artifact_response: object, *, run_id: str, head_sha: str) -> dict[str, str]:
if not isinstance(artifact_response, dict):
raise ValueError("artifact metadata must be a JSON object")
artifacts = artifact_response.get("artifacts")
if not isinstance(artifacts, list) or len(artifacts) != 1:
raise ValueError("expected exactly one release descriptor artifact")
artifact = artifacts[0]
if not isinstance(artifact, dict):
raise ValueError("release artifact metadata is invalid")
name = artifact.get("name")
match = DESCRIPTOR_RE.fullmatch(name) if isinstance(name, str) else None
if not match or match.group(1) != run_id:
raise ValueError("release artifact name does not bind the source run and descriptor digest")
if artifact.get("expired") is True:
raise ValueError("release descriptor artifact has expired")
source = artifact.get("workflow_run")
if not isinstance(source, dict) or (
str(source.get("id")) != run_id
or source.get("head_sha") != head_sha
or source.get("head_branch") != "main"
or source.get("event") != "workflow_run"
or source.get("conclusion") != "success"
):
raise ValueError("artifact metadata does not match the successful source release run")
return {"run_id": run_id, "commit": head_sha, "descriptor_sha256": match.group(2)}
def _source_from_environment() -> dict[str, str]:
return {
"workflow": os.environ.get("SOURCE_WORKFLOW", ""),
"event": os.environ.get("SOURCE_EVENT", ""),
"conclusion": os.environ.get("SOURCE_CONCLUSION", ""),
"branch": os.environ.get("SOURCE_BRANCH", ""),
"run_id": os.environ.get("SOURCE_RUN_ID", ""),
"head_sha": os.environ.get("SOURCE_SHA", ""),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("stage", choices=("validate-ci-source", "validate-handoff-source", "dry-run"))
parser.add_argument("--artifact-metadata", type=Path)
args = parser.parse_args()
try:
source = _source_from_environment()
stage = "release" if args.stage == "validate-ci-source" else "handoff"
validate_source(**source, stage=stage, current_main=latest_main_sha())
if args.stage == "validate-ci-source":
print(f"PASS successful CI source {source['head_sha']} is current main")
return 0
if args.artifact_metadata is None:
print(
"DRY-RUN only: source release is current, but artifact metadata was not read; "
"host handoff is unavailable"
)
return 0
response = json.loads(args.artifact_metadata.read_text(encoding="utf-8"))
request = descriptor_request(response, run_id=source["run_id"], head_sha=source["head_sha"])
except (OSError, UnicodeError, json.JSONDecodeError, subprocess.CalledProcessError, ValueError) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print(
"DRY-RUN only: validated handoff tuple "
f"run_id={request['run_id']} commit={request['commit']} "
f"descriptor_sha256={request['descriptor_sha256']}; ZIP bytes were not fetched and no host connection was opened"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+127 -8
View File
@@ -1,30 +1,149 @@
import contextlib
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import release_handoff
from release_handoff import descriptor_request, validate_source
ROOT = Path(__file__).resolve().parents[2]
GITEA_UPLOAD_ARTIFACT = "https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc"
class ReleaseWorkflowTests(unittest.TestCase):
def test_publisher_is_manual_main_only_and_requires_registry_credentials(self):
def test_publisher_runs_only_after_successful_main_ci_and_binds_source_sha(self):
workflow = (ROOT / ".gitea/workflows/release-images.yaml").read_text(encoding="utf-8")
self.assertIn("workflow_dispatch:", workflow)
self.assertIn("if: gitea.ref == 'refs/heads/main'", workflow)
self.assertNotIn("pull_request:", workflow)
self.assertIn('workflows: ["CI"]', workflow)
self.assertIn("branches: [main]", workflow)
self.assertIn("types: [completed]", workflow)
self.assertIn("gitea.event.workflow_run.conclusion == 'success'", workflow)
self.assertIn("gitea.event.workflow_run.event == 'push'", workflow)
self.assertIn("gitea.event.workflow_run.head_branch == 'main'", workflow)
self.assertIn("ref: ${{ gitea.event.workflow_run.head_sha }}", workflow)
self.assertIn("python3 infra/deploy/release_handoff.py validate-ci-source", workflow)
self.assertNotIn("workflow_dispatch:", workflow)
self.assertIn("secrets.REGISTRY_USERNAME", workflow)
self.assertIn("secrets.REGISTRY_PASSWORD", workflow)
self.assertIn(GITEA_UPLOAD_ARTIFACT, workflow)
self.assertIn("release/release-manifest.json.sha256", workflow)
self.assertIn("retention-days: 90", workflow)
self.assertIn("if-no-files-found: error", workflow)
self.assertNotIn("github.run_attempt", workflow)
self.assertIn("${{ github.run_id }}", workflow)
self.assertNotIn("gitea.run_attempt", workflow)
self.assertIn("${{ gitea.run_id }}", workflow)
self.assertIn("${{ gitea.event.workflow_run.head_sha }}", workflow)
self.assertIn("steps.descriptor.outputs.descriptor_sha256", workflow)
self.assertIn("(cd release && sha256sum -c release-manifest.json.sha256)", workflow)
self.assertNotIn("docker compose", workflow)
guard = workflow.index("Verify successful CI source before using registry credentials")
registry_secret = workflow.index("Require explicitly configured Gitea registry credentials")
self.assertLess(guard, registry_secret)
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('workflows: ["Release production images"]', workflow)
self.assertIn("branches: [main]", workflow)
self.assertIn("types: [completed]", workflow)
self.assertIn("gitea.event.workflow_run.conclusion == 'success'", workflow)
self.assertIn("gitea.event.workflow_run.event == 'workflow_run'", workflow)
self.assertIn("gitea.event.workflow_run.head_branch == 'main'", workflow)
self.assertIn("SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}", workflow)
self.assertNotIn("SOURCE_RUN_ID: ${{ gitea.run_id }}", workflow)
self.assertIn("gitea-api GET", workflow)
self.assertNotIn("curl ", workflow)
self.assertNotIn("AKYLDASH_DEPLOY_SSH_KEY", workflow)
self.assertNotIn("docker.sock", workflow)
self.assertIn("host handoff are not verified", workflow)
def test_source_guards_reject_wrong_event_or_stale_main(self):
valid = dict(
workflow="CI", event="push", conclusion="success", branch="main",
run_id="301", head_sha="a" * 40, stage="release", current_main="a" * 40,
)
validate_source(**valid)
for change in (
{"event": "pull_request"},
{"branch": "feature/test"},
{"conclusion": "failure"},
{"workflow": "untrusted workflow"},
{"head_sha": "A" * 40},
{"run_id": "3/../1"},
{"current_main": "b" * 40},
):
with self.subTest(change=change), self.assertRaises(ValueError):
validate_source(**(valid | change))
def test_handoff_tuple_binds_artifact_run_commit_and_digest(self):
run_id = "302"
head_sha = "a" * 40
digest = "b" * 64
response = {
"artifacts": [{
"id": 9,
"name": f"release-descriptor-{run_id}-{digest}",
"expired": False,
"workflow_run": {
"id": int(run_id), "head_sha": head_sha, "head_branch": "main",
"event": "workflow_run", "conclusion": "success",
},
}]
}
request = descriptor_request(response, run_id=run_id, head_sha=head_sha)
self.assertEqual(request, {"run_id": run_id, "commit": head_sha, "descriptor_sha256": digest})
self.assertEqual(set(request), {"run_id", "commit", "descriptor_sha256"})
for artifact in (
{**response["artifacts"][0], "name": f"release-descriptor-303-{digest}"},
{**response["artifacts"][0], "name": f"release-descriptor-{run_id}-not-a-digest"},
{**response["artifacts"][0], "workflow_run": {"id": 303}},
{**response["artifacts"][0], "expired": True},
):
with self.subTest(artifact=artifact), self.assertRaises(ValueError):
descriptor_request({"artifacts": [artifact]}, run_id=run_id, head_sha=head_sha)
with self.assertRaises(ValueError):
descriptor_request({"artifacts": [response["artifacts"][0], response["artifacts"][0]]}, run_id=run_id, head_sha=head_sha)
def test_dry_run_validates_the_tuple_without_opening_a_host_connection(self):
run_id = "302"
head_sha = "a" * 40
artifacts = {
"artifacts": [{
"name": f"release-descriptor-{run_id}-{'b' * 64}",
"expired": False,
"workflow_run": {
"id": int(run_id), "head_sha": head_sha, "head_branch": "main",
"event": "workflow_run", "conclusion": "success",
},
}]
}
with tempfile.TemporaryDirectory() as directory:
metadata = Path(directory) / "artifacts.json"
metadata.write_text(json.dumps(artifacts), encoding="utf-8")
environment = {
"SOURCE_WORKFLOW": "Release production images",
"SOURCE_EVENT": "workflow_run",
"SOURCE_CONCLUSION": "success",
"SOURCE_BRANCH": "main",
"SOURCE_RUN_ID": run_id,
"SOURCE_SHA": head_sha,
}
output = io.StringIO()
with (
patch.dict(os.environ, environment),
patch.object(sys, "argv", ["release_handoff.py", "dry-run", "--artifact-metadata", str(metadata)]),
patch.object(release_handoff, "latest_main_sha", return_value=head_sha),
contextlib.redirect_stdout(output),
):
self.assertEqual(release_handoff.main(), 0)
self.assertIn("descriptor_sha256=" + "b" * 64, output.getvalue())
self.assertIn("ZIP bytes were not fetched", output.getvalue())
self.assertIn("no host connection was opened", output.getvalue())
def test_pr_ci_has_small_synthetic_artifact_upload_without_registry_secrets(self):
workflow = (ROOT / ".gitea/workflows/ci.yaml").read_text(encoding="utf-8")
self.assertIn("Verify Gitea runtime artifact upload", workflow)
@@ -99,11 +218,11 @@ class ReleaseWorkflowTests(unittest.TestCase):
release_builder,
)
self.assertIn(
"destination: git.vakanaut.ru/admin/akyldash/backend:${{ github.sha }}-${{ github.run_id }}",
"destination: git.vakanaut.ru/admin/akyldash/backend:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}",
workflow,
)
self.assertIn(
"destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ github.sha }}-${{ github.run_id }}",
"destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}",
workflow,
)
examples = (