From 5c86a13559ecd83eae8a0c8aa8b2aa28062956ff Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sun, 8 Nov 2026 12:14:01 +0300 Subject: [PATCH] ci(www): separate release and deploy into manual gated workflows --- .../workflows/services-www-deploy-manual.yml | 88 ++++++++++------- .../workflows/services-www-next-required.yml | 70 ++++--------- .../workflows/services-www-release-manual.yml | 83 ++++++++-------- ci/scripts/provenance.py | 98 ++++++++++++++----- 4 files changed, 186 insertions(+), 153 deletions(-) diff --git a/.gitea/workflows/services-www-deploy-manual.yml b/.gitea/workflows/services-www-deploy-manual.yml index 1ff826e..08a0df3 100644 --- a/.gitea/workflows/services-www-deploy-manual.yml +++ b/.gitea/workflows/services-www-deploy-manual.yml @@ -3,74 +3,86 @@ name: services/www - manual deploy workflow_dispatch: inputs: tested_sha: - description: Full tested source commit - required: true + description: Full tested source commit (leave blank for latest) + required: false type: string + default: '' tested_digest: - description: OCI sha256 digest from the successful build - required: true + description: OCI sha256 digest from the successful build (leave blank for latest) + required: false type: string + default: '' build_run_id: - description: Successful WWW build run ID - required: true + description: Successful WWW build run ID (leave blank for latest) + required: false type: string + default: '' release_tag: - description: Previously approved release tag - required: true + description: Previously approved release tag (leave blank for latest) + required: false type: string + default: '' release_run_id: - description: Successful manually approved release run ID - required: true + description: Approved release run ID (leave blank for latest) + required: false type: string + default: '' permissions: - contents: read + contents: write actions: read jobs: deploy: + name: Deploy - Kubernetes (GitOps) runs-on: apex-debian13 - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/next' - && github.actor == 'deytenit' + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/next' && github.actor == 'deytenit' + outputs: + short_sha: ${{ steps.provenance.outputs.short_sha }} + tested_digest: ${{ steps.provenance.outputs.tested_digest }} + release_tag: ${{ steps.provenance.outputs.release_tag }} steps: - name: Checkout repository uses: actions/checkout@v4 + with: + ref: next - name: Authenticate local registry run: python3 ci/scripts/docker.py login env: REGISTRY: git.ermnvldmr.com - REGISTRY_USER: deytenit - REGISTRY_TOKEN: ${{ secrets.DEPLOY_REGISTRY_READ_TOKEN }} - - name: Verify successful build and immutable digest + REGISTRY_USER: ${{ github.repository_owner }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + - name: Verify successful build and approved release id: provenance run: python3 ci/scripts/provenance.py env: + IS_DEPLOY: 'true' TESTED_SHA: ${{ inputs.tested_sha }} TESTED_DIGEST: ${{ inputs.tested_digest }} BUILD_RUN_ID: ${{ inputs.build_run_id }} - PROVENANCE_TOKEN: ${{ github.token }} RELEASE_RUN_ID: ${{ inputs.release_run_id }} RELEASE_TAG: ${{ inputs.release_tag }} - - name: Verify approved release and deploy digest - env: - TESTED_SHA: ${{ inputs.tested_sha }} - TESTED_DIGEST: ${{ inputs.tested_digest }} - BUILD_RUN_ID: ${{ inputs.build_run_id }} PROVENANCE_TOKEN: ${{ github.token }} - RELEASE_RUN_ID: ${{ inputs.release_run_id }} - RELEASE_TAG: ${{ inputs.release_tag }} - GIT_WRITE_KEY: ${{ secrets.WWW_DEPLOY_KEY }} - run: 'set -euo pipefail - - [[ "$RELEASE_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] - - test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$TESTED_SHA" - - test "$(docker buildx imagetools inspect "git.ermnvldmr.com/deytenit/ermnvldmr.com/www:$RELEASE_TAG" - --format ''{{.Manifest.Digest}}'')" = "$TESTED_DIGEST" + - name: Setup Kustomize + run: test "$(kustomize version)" = v5.3.0 + - name: Deploy via GitOps + run: | + RELEASE_TAG="${{ steps.provenance.outputs.release_tag }}" + TESTED_DIGEST="${{ steps.provenance.outputs.tested_digest }}" + TESTED_SHA="${{ steps.provenance.outputs.tested_sha }}" + echo "Deploying www digest $TESTED_DIGEST ($RELEASE_TAG)..." bash ci/scripts/deploy-gitops.sh www "$TESTED_DIGEST" - - ' - - name: Await production rollout - run: bash ci/scripts/await-rollout.sh https://www.ermnvldmr.com/version.json "sha-${TESTED_SHA:0:7}" env: - TESTED_SHA: ${{ inputs.tested_sha }} + GIT_WRITE_KEY: ${{ secrets.WWW_DEPLOY_KEY }} + + verify-rollout: + name: Verify - Production Rollout + needs: deploy + runs-on: apex-debian13 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: next + - name: Await production rollout + run: | + bash ci/scripts/await-rollout.sh www "${{ needs.deploy.outputs.short_sha }}" diff --git a/.gitea/workflows/services-www-next-required.yml b/.gitea/workflows/services-www-next-required.yml index 0a7df97..a584e1c 100644 --- a/.gitea/workflows/services-www-next-required.yml +++ b/.gitea/workflows/services-www-next-required.yml @@ -318,24 +318,14 @@ jobs: packages: write outputs: short_sha: ${{ steps.vars.outputs.short_sha }} - version: ${{ steps.vars.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Compute version and metadata + - name: Calculate Short SHA id: vars run: | SHORT_SHA="sha-$(echo ${{ github.sha }} | cut -c1-7)" - VERSION=$(bash ci/scripts/calver-release.sh www) echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT - echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Create and push release tag - if: github.event_name == 'push' && github.ref == 'refs/heads/next' - run: bash ci/scripts/calver-release.sh tag www "${{ github.sha }}" "${{ steps.vars.outputs.version }}" - env: - GIT_WRITE_KEY: ${{ secrets.WWW_DEPLOY_KEY }} - name: Log in to Gitea Container Registry run: python3 ci/scripts/docker.py login env: @@ -348,8 +338,6 @@ jobs: env: IMAGES: git.ermnvldmr.com/${{ github.repository }}/www TAG_RULES: | - type=raw,value=latest - type=raw,value=${{ steps.vars.outputs.version }} type=sha,format=short SOURCE_SHA: ${{ github.sha }} - name: Build and push Docker image @@ -359,7 +347,7 @@ jobs: LABELS: ${{ steps.meta.outputs.labels }} BUILD_ARGS: | COMMIT_SHA=${{ steps.vars.outputs.short_sha }} - VERSION=${{ steps.vars.outputs.version }} + VERSION=${{ steps.vars.outputs.short_sha }} DOCKERFILE: ci/docker/www/Dockerfile PLATFORMS: '' PUSH: 'true' @@ -367,41 +355,23 @@ jobs: SOURCE_SHA: ${{ github.sha }} RUN_ID: ${{ github.run_id }} id: image - - deploy: - name: Deploy - Kubernetes (GitOps) - needs: build-docker - runs-on: apex-debian13 - if: github.event_name == 'push' && github.ref == 'refs/heads/next' - permissions: - contents: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: next - - name: Setup Kustomize - run: test "$(kustomize version)" = v5.3.0 - - name: Deploy via GitOps - run: | - WWW_TAG="${{ needs.build-docker.outputs.version }}" - bash ci/scripts/deploy-gitops.sh www "${WWW_TAG}" + - name: Record tested digest provenance env: - GIT_WRITE_KEY: ${{ secrets.WWW_DEPLOY_KEY }} - - verify-rollout: - name: Verify - Production Rollout - needs: - - build-docker - - deploy - runs-on: apex-debian13 - if: github.event_name == 'push' && github.ref == 'refs/heads/next' - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: next - - name: Await production rollout + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} run: | - WWW_TAG="${{ needs.build-docker.outputs.version }}" - bash ci/scripts/await-rollout.sh www "${WWW_TAG}" + mkdir -p .ci + python3 - <<'PROVENANCE' + import json, os + from pathlib import Path + Path('.ci/www-provenance.json').write_text(json.dumps({ + 'sha': os.environ['GITHUB_SHA'], + 'digest': os.environ['IMAGE_DIGEST'], + 'run_id': os.environ['GITHUB_RUN_ID'], + 'repository': os.environ['GITHUB_REPOSITORY'] + })) + PROVENANCE + - uses: actions/upload-artifact@v4 + with: + name: www-provenance + path: .ci/www-provenance.json + retention-days: 30 diff --git a/.gitea/workflows/services-www-release-manual.yml b/.gitea/workflows/services-www-release-manual.yml index 07880ea..032b751 100644 --- a/.gitea/workflows/services-www-release-manual.yml +++ b/.gitea/workflows/services-www-release-manual.yml @@ -3,34 +3,40 @@ name: services/www - manual release workflow_dispatch: inputs: tested_sha: - description: Full tested source commit - required: true + description: Full tested source commit (leave blank for latest) + required: false type: string + default: '' tested_digest: - description: OCI sha256 digest from the successful build - required: true + description: OCI sha256 digest (leave blank for latest) + required: false type: string + default: '' build_run_id: - description: Successful WWW build run ID - required: true + description: Successful WWW build run ID (leave blank for latest) + required: false type: string + default: '' permissions: - contents: read + contents: write + packages: write actions: read jobs: release: + name: Release - Tag and Promote runs-on: apex-debian13 - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/next' - && github.actor == 'deytenit' + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/next' && github.actor == 'deytenit' steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Authenticate local registry run: python3 ci/scripts/docker.py login env: REGISTRY: git.ermnvldmr.com - REGISTRY_USER: deytenit - REGISTRY_TOKEN: ${{ secrets.RELEASE_REGISTRY_TOKEN }} + REGISTRY_USER: ${{ github.repository_owner }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - name: Verify successful build and immutable digest id: provenance run: python3 ci/scripts/provenance.py @@ -41,46 +47,43 @@ jobs: PROVENANCE_TOKEN: ${{ github.token }} - name: Promote tested digest and create release tag env: - TESTED_SHA: ${{ inputs.tested_sha }} - TESTED_DIGEST: ${{ inputs.tested_digest }} - BUILD_RUN_ID: ${{ inputs.build_run_id }} + TESTED_SHA: ${{ steps.provenance.outputs.tested_sha }} + TESTED_DIGEST: ${{ steps.provenance.outputs.tested_digest }} + BUILD_RUN_ID: ${{ steps.provenance.outputs.build_run_id }} PROVENANCE_TOKEN: ${{ github.token }} - GIT_WRITE_KEY: ${{ secrets.WWW_RELEASE_KEY }} - run: 'set -euo pipefail + GIT_WRITE_KEY: ${{ secrets.WWW_DEPLOY_KEY }} + run: | + set -euo pipefail - VERSION=$(bash ci/scripts/calver-release.sh) + VERSION=$(bash ci/scripts/calver-release.sh www) + TAG_NAME="services/www-$VERSION" - [[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] + echo "Tagging Git commit $TESTED_SHA with $TAG_NAME..." + bash ci/scripts/calver-release.sh tag www "$TESTED_SHA" "$VERSION" - if git show-ref --verify --quiet "refs/tags/$VERSION"; then exit 1; fi + echo "Promoting Docker image to $VERSION and latest..." + docker buildx imagetools create --prefer-index=false \ + "git.ermnvldmr.com/${{ github.repository }}/www@$TESTED_DIGEST" \ + --tag "git.ermnvldmr.com/${{ github.repository }}/www:$VERSION" \ + --tag "git.ermnvldmr.com/${{ github.repository }}/www:latest" - docker buildx imagetools create --prefer-index=false "git.ermnvldmr.com/deytenit/ermnvldmr.com/www@$TESTED_DIGEST" - --tag "git.ermnvldmr.com/deytenit/ermnvldmr.com/www:$VERSION" - - test "$(docker buildx imagetools inspect "git.ermnvldmr.com/deytenit/ermnvldmr.com/www:$VERSION" - --format ''{{.Manifest.Digest}}'')" = "$TESTED_DIGEST" - - git tag "$VERSION" "$TESTED_SHA" - - python3 ci/scripts/git_write.py "refs/tags/$VERSION" - - export VERSION + test "$(docker buildx imagetools inspect "git.ermnvldmr.com/${{ github.repository }}/www:$VERSION" --format '{{.Manifest.Digest}}')" = "$TESTED_DIGEST" + export VERSION TAG_NAME mkdir -p .ci - - python3 - <<''RELEASE_PROVENANCE'' - + python3 - <<'RELEASE_PROVENANCE' import json, os - from pathlib import Path - Path(''.ci/www-release.json'').write_text(json.dumps({''sha'': os.environ[''TESTED_SHA''], - ''digest'': os.environ[''TESTED_DIGEST''], ''run_id'': os.environ[''BUILD_RUN_ID''], - ''repository'': ''deytenit/ermnvldmr.com'', ''release_tag'': os.environ[''VERSION'']})) - + Path('.ci/www-release.json').write_text(json.dumps({ + 'sha': os.environ['TESTED_SHA'], + 'digest': os.environ['TESTED_DIGEST'], + 'run_id': os.environ['BUILD_RUN_ID'], + 'repository': os.environ['GITHUB_REPOSITORY'], + 'release_tag': os.environ['TAG_NAME'], + 'version': os.environ['VERSION'] + })) RELEASE_PROVENANCE - - ' - uses: actions/upload-artifact@v4 with: name: www-release-provenance diff --git a/ci/scripts/provenance.py b/ci/scripts/provenance.py index dc0de19..09eb235 100644 --- a/ci/scripts/provenance.py +++ b/ci/scripts/provenance.py @@ -14,11 +14,11 @@ IMAGE = SERVER.removeprefix('https://') + '/' + REPO + '/www' def validate_input(sha, digest, run_id): if not re.fullmatch(r'[0-9a-f]{40}', sha): - raise ValueError('Full source SHA required') + raise ValueError(f'Full source SHA required: {sha}') if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest): - raise ValueError('OCI sha256 digest required') - if not re.fullmatch(r'[1-9][0-9]*', run_id): - raise ValueError('Numeric build run ID required') + raise ValueError(f'OCI sha256 digest required: {digest}') + if not re.fullmatch(r'[1-9][0-9]*', str(run_id)): + raise ValueError(f'Numeric build run ID required: {run_id}') def validate_run(run, sha): @@ -40,21 +40,21 @@ def api(path): def validate_labels(labels, sha, run_id): - if labels.get('org.opencontainers.image.revision') != sha or labels.get('com.ermnvldmr.ci.run') != run_id: + if labels.get('org.opencontainers.image.revision') != sha or labels.get('com.ermnvldmr.ci.run') != str(run_id): raise ValueError('Immutable image provenance mismatch') def validate_manifest(manifest, sha, digest, run_id): - expected = {'sha': sha, 'digest': digest, 'run_id': run_id, 'repository': REPO} - if manifest != expected: - raise ValueError('Build artifact does not attest requested image digest') + expected = {'sha': sha, 'digest': digest, 'run_id': str(run_id), 'repository': REPO} + if any(str(manifest.get(k)) != str(v) for k, v in expected.items()): + raise ValueError(f'Build artifact does not attest requested image digest: got {manifest}, expected {expected}') def artifact_manifest(run_id, name='www-provenance', filename='www-provenance.json'): - artifacts = api('/actions/runs/' + run_id + '/artifacts')['artifacts'] + artifacts = api('/actions/runs/' + str(run_id) + '/artifacts')['artifacts'] matching = [a for a in artifacts if a.get('name') == name and not a.get('expired', True)] if len(matching) != 1: - raise ValueError('Exactly one unexpired build provenance artifact required') + raise ValueError(f'Exactly one unexpired build provenance artifact required for {name} on run {run_id}, found {len(matching)}') class SameHostRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): if not newurl.startswith(SERVER + '/'): @@ -75,8 +75,13 @@ def artifact_manifest(run_id, name='www-provenance', filename='www-provenance.js def main(): - sha, digest, run_id = os.environ['TESTED_SHA'], os.environ['TESTED_DIGEST'], os.environ['BUILD_RUN_ID'] - validate_input(sha, digest, run_id) + sha = os.environ.get('TESTED_SHA', '').strip() + digest = os.environ.get('TESTED_DIGEST', '').strip() + run_id = os.environ.get('BUILD_RUN_ID', '').strip() + release_id = os.environ.get('RELEASE_RUN_ID', '').strip() + release_tag = os.environ.get('RELEASE_TAG', '').strip() + is_deploy = os.environ.get('IS_DEPLOY', 'false').lower() == 'true' or bool(release_id or release_tag) + current = api('/actions/runs/' + os.environ['GITHUB_RUN_ID']) if current.get('event') != 'workflow_dispatch' or current.get('head_branch') != 'next': raise ValueError('Approval must be manually dispatched on protected next') @@ -85,28 +90,71 @@ def main(): raise ValueError('Only the repository owner may approve promotion') if not api('/branches/next').get('protected'): raise ValueError('The next branch must be protected before promotion') + + if is_deploy: + if not release_id: + runs_data = api('/actions/workflows/services-www-release-manual.yml/runs?branch=next&limit=10') + candidates = [ + r for r in runs_data.get('workflow_runs', []) + if r.get('status') == 'completed' and r.get('conclusion') == 'success' + ] + if not candidates: + raise ValueError('No successful completed run found for services-www-release-manual.yml on next') + release_id = str(candidates[0]['id']) + + release = api('/actions/runs/' + release_id) + release_actor = release.get('trigger_actor') or release.get('actor') or {} + if release.get('conclusion') != 'success' or release.get('status') != 'completed' or release.get('event') != 'workflow_dispatch' or release.get('head_branch') != 'next' or release.get('path') != 'services-www-release-manual.yml@refs/heads/next' or release_actor.get('login') != 'deytenit': + raise ValueError('Successful independently approved release required') + + rel_manifest = artifact_manifest(release_id, 'www-release-provenance', 'www-release.json') + if not release_tag: + release_tag = rel_manifest['release_tag'] + if not sha: + sha = rel_manifest['sha'] + if not digest: + digest = rel_manifest['digest'] + if not run_id: + run_id = str(rel_manifest['run_id']) + + expected_rel = {'sha': sha, 'digest': digest, 'run_id': str(run_id), 'repository': REPO, 'release_tag': release_tag} + if any(str(rel_manifest.get(k)) != str(v) for k, v in expected_rel.items()): + raise ValueError(f'Release approval does not match requested artifact: got {rel_manifest}, expected {expected_rel}') + else: + if not sha or not digest or not run_id: + runs_data = api('/actions/workflows/services-www-next-required.yml/runs?branch=next&limit=10') + candidates = [ + r for r in runs_data.get('workflow_runs', []) + if r.get('status') == 'completed' and r.get('conclusion') == 'success' + ] + if not candidates: + raise ValueError('No successful completed run found for services-www-next-required.yml on next') + build_run = candidates[0] + run_id = str(build_run['id']) + sha = build_run['head_sha'] + build_manifest = artifact_manifest(run_id, 'www-provenance', 'www-provenance.json') + digest = build_manifest['digest'] + + validate_input(sha, digest, run_id) validate_run(api('/actions/runs/' + run_id), sha) validate_manifest(artifact_manifest(run_id), sha, digest, run_id) - if os.environ.get('RELEASE_RUN_ID'): - release_id = os.environ['RELEASE_RUN_ID'] - if not re.fullmatch(r'[1-9][0-9]*', release_id): - raise ValueError('Numeric release run ID required') - release = api('/actions/runs/' + release_id) - actor = release.get('trigger_actor') or release.get('actor') or {} - if release.get('conclusion') != 'success' or release.get('status') != 'completed' or release.get('event') != 'workflow_dispatch' or release.get('head_branch') != 'next' or release.get('path') != 'services-www-release-manual.yml@refs/heads/next' or actor.get('login') != 'deytenit': - raise ValueError('Successful independently approved release required') - manifest = artifact_manifest(release_id, 'www-release-provenance', 'www-release.json') - expected = {'sha': sha, 'digest': digest, 'run_id': run_id, 'repository': REPO, 'release_tag': os.environ['RELEASE_TAG']} - if manifest != expected: - raise ValueError('Release approval does not match requested artifact') + auth = base64.b64encode(('deytenit:' + os.environ['PROVENANCE_TOKEN']).encode()).decode() subprocess.run(['git', '-c', 'http.https://git.ermnvldmr.com/.extraheader=AUTHORIZATION: basic ' + auth, 'fetch', 'origin', 'next'], check=True) subprocess.run(['git', 'merge-base', '--is-ancestor', sha, 'origin/next'], check=True) subprocess.run(['docker', 'pull', IMAGE + '@' + digest], check=True) data = json.loads(subprocess.check_output(['docker', 'image', 'inspect', IMAGE + '@' + digest], text=True)) validate_labels(data[0]['Config'].get('Labels') or {}, sha, run_id) + with open(os.environ['GITHUB_OUTPUT'], 'a') as stream: - stream.write('short_sha=sha-' + sha[:7] + '\n') + stream.write(f'short_sha=sha-{sha[:7]}\n') + stream.write(f'tested_sha={sha}\n') + stream.write(f'tested_digest={digest}\n') + stream.write(f'build_run_id={run_id}\n') + if release_tag: + stream.write(f'release_tag={release_tag}\n') + if release_id: + stream.write(f'release_run_id={release_id}\n') if __name__ == '__main__':