ci: chain release and dry-run handoff
This commit is contained in:
1 parent
f1573edfa7
commit
74aa167d16
9 files changed
+457
-57
No files matched your search
@@ -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())"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user