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: 'Stable tag to (re-)publish (e.g. v0.11.0)' required: true permissions: contents: read 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: 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 - 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: 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" "$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: | 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"