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
parent
fff2d017be
commit
10486672a5
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
|
||||||
Loading…
Reference in New Issue