ci: build+push prod images, gated auto-promote to public

Backbone sub-project #1, CI half (verified with actionlint; the runtime proof is
the first rc/stable tag push — these jobs cannot run locally).

- ci-staging.yml:
  - new `images` job (needs: test): buildx builds clusev-app (Dockerfile) +
    clusev-terminal (docker/terminal/Dockerfile) → private GHCR, tagged by
    version; then a framework smoke-boot (`php artisan route:list`) so a class
    stripped by export-ignore that is still reachable on boot fails before promote.
  - `deploy-staging` widened to any tag; pulls the CI-built private image
    (CLUSEV_IMAGE/CLUSEV_TERMINAL_IMAGE + CLUSEV_PULL=1) instead of building.
  - new gated `promote` job (needs: [test, images], if success + stable vX.Y.Z,
    no -rc): copy the exact image digests to the public GHCR, run promote.sh
    (leak-guard + export-ignore tree), write release-images.lock, push code+tag.
- promote-public.yml: repurposed as a guarded manual fallback mirroring the auto
  promote (image copy + lock + guard). Dropped the "beta channel" naming.
- promote-stable.yml: removed (no beta→stable step in a single-channel model).

Deploy inputs are GitHub vars/secrets (GHCR_OWNER, PUBLIC_GHCR_OWNER,
PUBLIC_REPO_SLUG, STAGING_*, GHCR_TOKEN, PUBLIC_REPO_TOKEN, PROMOTE_FORBIDDEN) —
never hardcoded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-03 20:15:03 +02:00
parent fff2d017be
commit 10486672a5
5 changed files with 201 additions and 63 deletions

View File

@ -34,6 +34,22 @@ rules.md
CLAUDE.md CLAUDE.md
README.md README.md
# Dev-only release bridge — must NOT bake into the PUBLIC prod image (it carries the private
# upstream references). Also export-ignored from the public repo. Dev keeps it via the bind-mount;
# only the built (prod/public) image is stripped. The /release route is release_controls-gated
# (false in prod), so these classes are never loaded at runtime here.
app/Livewire/Release
app/Services/ReleaseBridge.php
app/Services/ReleasePlanner.php
app/Services/PipelineStatus.php
app/Services/PromotionService.php
resources/views/livewire/release
lang/de/release.php
lang/en/release.php
docker/release
scripts/promote.sh
scripts/promote.test.sh
# Compose files (not needed inside the image) # Compose files (not needed inside the image)
docker-compose.yml docker-compose.yml
docker-compose.prod.yml docker-compose.prod.yml

1
.gitattributes vendored
View File

@ -26,6 +26,7 @@
/app/Services/ReleaseBridge.php export-ignore /app/Services/ReleaseBridge.php export-ignore
/app/Services/ReleasePlanner.php export-ignore /app/Services/ReleasePlanner.php export-ignore
/app/Services/PipelineStatus.php export-ignore /app/Services/PipelineStatus.php export-ignore
/app/Services/PromotionService.php export-ignore
/resources/views/livewire/release export-ignore /resources/views/livewire/release export-ignore
/lang/de/release.php export-ignore /lang/de/release.php export-ignore
/lang/en/release.php export-ignore /lang/en/release.php export-ignore

View File

@ -20,27 +20,151 @@ jobs:
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
node-version: '20' node-version: '20'
- run: npm ci
# Build the Vite manifest BEFORE the tests: full-page tests render @vite layouts, which 500 # Build the Vite manifest BEFORE the tests: full-page tests render @vite layouts, which 500
# without public/build/manifest.json (it is gitignored, so absent on a fresh CI checkout). # without public/build/manifest.json (it is gitignored, so absent on a fresh CI checkout).
- run: npm ci
- run: npm run build - run: npm run build
- run: php artisan test - run: php artisan test
- run: ./vendor/bin/pint --test - run: ./vendor/bin/pint --test
- name: shellcheck - name: shellcheck
run: | run: |
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \ docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \
docker/wg/clusev-wg.sh install.sh update.sh scripts/*.sh docker/wg/clusev-wg.sh docker/restart-sentinel/watch.sh install.sh update.sh scripts/*.sh
deploy-staging: # Build the two prod images and push them to the PRIVATE GHCR, tagged by the version. The promote
# job later copies the exact digests to the public GHCR — the image is only public once the release is.
images:
needs: test needs: test
# Only beta tags, and only when a self-hosted internal runner is wired up. runs-on: ubuntu-latest
if: ${{ contains(github.ref_name, '-beta') && vars.STAGING_ENABLED == 'true' }} permissions:
contents: read
packages: write
env:
TAG: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build + push app image (private GHCR, by version tag)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}
- name: Build + push terminal image
uses: docker/build-push-action@v6
with:
context: ./docker/terminal
file: docker/terminal/Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}
# Smoke: boot the framework inside the freshly built app image. `route:list` loads config +
# every service provider + registers all routes — so a class stripped by export-ignore that is
# still reachable on boot fails HERE, before the image can ever promote. No DB/Redis needed.
- name: Smoke-boot the app image
run: |
set -euo pipefail
docker run --rm \
-e APP_KEY="base64:$(head -c32 /dev/urandom | base64)" \
-e APP_ENV=production -e CLUSEV_RELEASE_CONTROLS=false \
"ghcr.io/${{ vars.GHCR_OWNER }}/clusev:$TAG" \
php artisan route:list --json > /dev/null
echo "smoke: framework booted + routes registered"
# Staging (self-hosted runner) pulls the freshly built private image — the first real exercise of
# the pull path. Only runs when a self-hosted runner is wired up (vars.STAGING_ENABLED).
deploy-staging:
needs: images
if: ${{ vars.STAGING_ENABLED == 'true' }}
runs-on: [self-hosted, clusev-staging] runs-on: [self-hosted, clusev-staging]
environment: staging environment: staging
steps: steps:
- name: Update the staging stack - name: Update the staging stack (pull the CI-built image)
env: env:
STAGING_PROJECT_DIR: ${{ vars.STAGING_PROJECT_DIR }} STAGING_PROJECT_DIR: ${{ vars.STAGING_PROJECT_DIR }}
CLUSEV_IMAGE: ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}
CLUSEV_TERMINAL_IMAGE: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}
CLUSEV_PULL: '1'
run: | run: |
cd "${STAGING_PROJECT_DIR:?STAGING_PROJECT_DIR not set}" cd "${STAGING_PROJECT_DIR:?STAGING_PROJECT_DIR not set}"
sudo ./update.sh sudo -E ./update.sh
# Auto-promote to PUBLIC — only a finalized stable tag (no -rc), and only after test + images are
# green (needs + success()). Copies the exact image digests to the public GHCR and publishes a
# Gitea-free code tree; promote.sh's leak-guard is the last line of defence.
promote:
needs: [test, images]
# Stable = a clean vX.Y.Z with NO pre-release suffix. Excludes -rc AND any accidental -beta/-alpha.
if: ${{ success() && startsWith(github.ref_name, 'v') && !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
steps:
# Hard stop: only an EXACT stable SemVer tag may cause a public side-effect. The `if:` above
# cannot regex, so `vfoo` / `v1.2` / `v2026` would slip through — fail here, before any publish.
- name: Enforce a clean stable SemVer tag
run: |
[[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| { echo "::error::not a stable vX.Y.Z tag: $GITHUB_REF_NAME"; exit 1; }
- uses: actions/checkout@v5
with:
fetch-depth: 0
path: src
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
# Read-only: resolve the private image digests. No public side-effect yet.
- name: Resolve private image digests
id: img
run: |
set -euo pipefail
appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}" --format '{{.Manifest.Digest}}')
termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}" --format '{{.Manifest.Digest}}')
{ echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
# Build + guard the public tree LOCALLY. promote.sh refuses on an already-published tag or a
# leak; nothing is published yet, so a refusal cannot leave an orphaned public image.
- name: Build the public tree (guarded, no publish)
env:
CLUSEV_PROMOTE_FORBIDDEN: ${{ secrets.PROMOTE_FORBIDDEN }}
run: |
set -euo pipefail
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "${{ github.ref_name }}" "$PWD/pub" "Release ${{ github.ref_name }}"
# release-images.lock — consumed by sub-project #2 (install/update pull).
printf '{ "version": "%s", "app": "ghcr.io/%s/clusev@%s", "terminal": "ghcr.io/%s/clusev-terminal@%s" }\n' \
"${{ github.ref_name }}" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \
"${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.termd }}" > pub/release-images.lock
git -C pub add release-images.lock
git -C pub commit -q --amend --no-edit
git -C pub tag -f "${{ github.ref_name }}"
# Publish AFTER every guard, and BEFORE the git push (publish-then-advertise): a public release
# ref therefore ALWAYS has a pullable image. A push failure after this leaves only unreferenced,
# content-addressed image tags — re-running the promote republishes them idempotently and retries
# the push; it never advertises a release without an image (which the reverse order would risk).
- name: Publish images to public GHCR
run: |
set -euo pipefail
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:${{ github.ref_name }}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}"
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}"
# Atomic: main + tag land together, or neither does.
- name: Push code + tag to public (atomic)
run: git -C pub push --atomic origin HEAD:main "refs/tags/${{ github.ref_name }}"

View File

@ -1,9 +1,13 @@
name: Promote to public (beta channel) name: Promote to public (manual fallback)
# Primary promotion is the gated `promote` job in ci-staging.yml (auto on a green stable tag). This
# manual dispatch re-runs the same procedure for a specific tag (e.g. after a promote.sh fix). It
# mirrors the auto job: resolve the exact image digests, build+guard the public code tree, publish
# the images, then push atomically. promote.sh refuses if the tag is already public — yank first.
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: 'Tag to publish (e.g. v0.10.0-beta1)' description: 'Stable tag to (re-)publish (e.g. v0.11.0)'
required: true required: true
permissions: permissions:
@ -13,27 +17,65 @@ jobs:
promote: promote:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
permissions:
contents: read
packages: write
# The tag reaches every run-step via this env var (never inline `${{ inputs.tag }}` in a shell —
# that would be a command-injection sink for a hand-typed input).
env:
TAG: ${{ inputs.tag }}
steps: steps:
- name: Checkout private repo at the tag - name: Enforce a clean stable SemVer tag
uses: actions/checkout@v5 run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| { echo "::error::not a stable vX.Y.Z tag: $TAG"; exit 1; }
- uses: actions/checkout@v5
with: with:
ref: ${{ inputs.tag }} ref: ${{ inputs.tag }}
fetch-depth: 0 fetch-depth: 0
path: src path: src
- name: Checkout public repo - uses: docker/login-action@v3
uses: actions/checkout@v5 with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Resolve private image digests
id: img
run: |
set -euo pipefail
appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:$TAG" --format '{{.Manifest.Digest}}')
termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:$TAG" --format '{{.Manifest.Digest}}')
{ echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
with: with:
repository: ${{ vars.PUBLIC_REPO_SLUG }} repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }} token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0 fetch-depth: 0
path: pub path: pub
- name: Promote (clean release commit + tag) - name: Build the public tree (guarded, no publish)
env:
CLUSEV_PROMOTE_FORBIDDEN: ${{ secrets.PROMOTE_FORBIDDEN }}
APP_DIGEST: ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}
TERM_DIGEST: ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}
run: | run: |
set -euo pipefail
git config --global user.name 'clusev-release' git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev' git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "${{ inputs.tag }}" "$PWD/pub" "Release ${{ inputs.tag }}" src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG"
- name: Push to public printf '{ "version": "%s", "app": "%s", "terminal": "%s" }\n' "$TAG" "$APP_DIGEST" "$TERM_DIGEST" > pub/release-images.lock
git -C pub add release-images.lock
git -C pub commit -q --amend --no-edit
git -C pub tag -f "$TAG"
# Publish AFTER guards, BEFORE the push (publish-then-advertise): a release ref always has an
# image. A push failure leaves only unreferenced idempotent image tags; re-run to recover.
- name: Publish images to public GHCR
env:
APP_SRC: ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}
TERM_SRC: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}
run: | run: |
git -C pub push origin HEAD:main set -euo pipefail
git -C pub push origin "refs/tags/${{ inputs.tag }}" docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:$TAG" "$APP_SRC"
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:$TAG" "$TERM_SRC"
- name: Push code + tag to public (atomic)
run: git -C pub push --atomic origin HEAD:main "refs/tags/$TAG"

View File

@ -1,45 +0,0 @@
name: Promote beta to stable
on:
workflow_dispatch:
inputs:
betaTag:
description: 'Beta tag to finalise (e.g. v0.10.0-beta3)'
required: true
stableTag:
description: 'Stable tag to publish (e.g. v0.10.0)'
required: true
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout private repo at the beta tag
uses: actions/checkout@v5
with:
ref: ${{ inputs.betaTag }}
fetch-depth: 0
path: src
- name: Tag the same commit as stable (in the private repo, for traceability)
run: |
git -C src tag "${{ inputs.stableTag }}" "${{ inputs.betaTag }}^{commit}"
- name: Checkout public repo
uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Promote stable (clean release commit + tag)
run: |
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "${{ inputs.stableTag }}" "$PWD/pub" "Release ${{ inputs.stableTag }}"
- name: Push to public
run: |
git -C pub push origin HEAD:main
git -C pub push origin "refs/tags/${{ inputs.stableTag }}"