diff --git a/.dockerignore b/.dockerignore index a73c39a..0a21a89 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,6 +34,22 @@ rules.md CLAUDE.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) docker-compose.yml docker-compose.prod.yml diff --git a/.gitattributes b/.gitattributes index fc7aa9b..e097f8f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,6 +26,7 @@ /app/Services/ReleaseBridge.php export-ignore /app/Services/ReleasePlanner.php export-ignore /app/Services/PipelineStatus.php export-ignore +/app/Services/PromotionService.php export-ignore /resources/views/livewire/release export-ignore /lang/de/release.php export-ignore /lang/en/release.php export-ignore diff --git a/.github/workflows/ci-staging.yml b/.github/workflows/ci-staging.yml index 3cbba91..4cac298 100644 --- a/.github/workflows/ci-staging.yml +++ b/.github/workflows/ci-staging.yml @@ -20,27 +20,151 @@ jobs: - uses: actions/setup-node@v5 with: node-version: '20' - - run: npm ci # 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). + - run: npm ci - run: npm run build - run: php artisan test - run: ./vendor/bin/pint --test - name: shellcheck run: | 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 - # Only beta tags, and only when a self-hosted internal runner is wired up. - if: ${{ contains(github.ref_name, '-beta') && vars.STAGING_ENABLED == 'true' }} + runs-on: ubuntu-latest + 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] environment: staging steps: - - name: Update the staging stack + - name: Update the staging stack (pull the CI-built image) env: 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: | 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 }}" diff --git a/.github/workflows/promote-public.yml b/.github/workflows/promote-public.yml index da6ff4c..d358973 100644 --- a/.github/workflows/promote-public.yml +++ b/.github/workflows/promote-public.yml @@ -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: workflow_dispatch: inputs: tag: - description: 'Tag to publish (e.g. v0.10.0-beta1)' + description: 'Stable tag to (re-)publish (e.g. v0.11.0)' required: true permissions: @@ -13,27 +17,65 @@ jobs: promote: runs-on: ubuntu-latest 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: - - name: Checkout private repo at the tag - uses: actions/checkout@v5 + - name: Enforce a clean stable SemVer tag + 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: ref: ${{ inputs.tag }} fetch-depth: 0 path: src - - name: Checkout public repo - uses: actions/checkout@v5 + - uses: docker/login-action@v3 + 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: repository: ${{ vars.PUBLIC_REPO_SLUG }} token: ${{ secrets.PUBLIC_REPO_TOKEN }} fetch-depth: 0 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: | + 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" "${{ inputs.tag }}" "$PWD/pub" "Release ${{ inputs.tag }}" - - name: Push to public + src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG" + 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: | - git -C pub push origin HEAD:main - git -C pub push origin "refs/tags/${{ inputs.tag }}" + set -euo pipefail + 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" diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml deleted file mode 100644 index 9a08658..0000000 --- a/.github/workflows/promote-stable.yml +++ /dev/null @@ -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 }}"