docs(plan): release-promotion phase A implementation plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-22 20:33:03 +02:00
parent 30034a564b
commit 06b5600641
1 changed files with 601 additions and 0 deletions

View File

@ -0,0 +1,601 @@
# Release-Promotion Phase A — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the URL-agnostic git/CI/CD infrastructure that lets Clusev be promoted dev (Gitea) → staging (GitHub private) → public (GitHub public) without any private repo URL in tracked files.
**Architecture:** The update source is read from `CLUSEV_REPOSITORY` (env), derived from the clone origin by a shared shell helper; tracked config defaults to empty (the public URL is injected later). GitHub Actions runs the test suite on `v*` tags and (optionally) deploys betas to staging; two `workflow_dispatch` workflows promote a tag to the public repo (beta channel) and re-tag it stable, using a `scripts/promote.sh` that copies the tag's tree (no `.git`/`.github`/`.env`) into the public repo.
**Tech Stack:** Laravel 13 config, Bash (POSIX-ish, shellcheck-clean), GitHub Actions, git, rsync-free `git archive`.
**Spec:** `docs/superpowers/specs/2026-06-22-3-repo-release-promotion-design.md`
---
## File Structure
- `config/clusev.php``repository` becomes env-driven (modify).
- `.env.example` — document `CLUSEV_REPOSITORY` (modify).
- `scripts/set-repository-url.sh` — derive `CLUSEV_REPOSITORY` from the clone origin, write to `.env` (create).
- `scripts/set-repository-url.test.sh` — its test (create).
- `scripts/promote.sh` — copy a tag's tree into the public repo + commit + tag (create).
- `scripts/promote.test.sh` — its test (create).
- `install.sh`, `update.sh` — call `scripts/set-repository-url.sh` (modify).
- `.github/workflows/ci-staging.yml` — tests on `v*`; optional staging deploy on `*-beta*` (create).
- `.github/workflows/promote-public.yml``workflow_dispatch` promote a tag to public (create).
- `.github/workflows/promote-stable.yml``workflow_dispatch` re-tag a beta as stable on public (create).
- `tests/Feature/ReleaseCheckerTest.php` — add a "no repository configured" degradation test (modify).
- `README.md` — document the 3-repo flow + where URLs/secrets plug in (modify).
**Conventions to follow:** run tooling in the container as uid 1002 (`docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app …`); shellcheck via `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <file>`; commit messages in English, end with the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` line; never echo the Gitea token (push via the sanitized pattern).
---
### Task 1: Repository URL is env-driven (no private URL in tracked config)
**Files:**
- Modify: `config/clusev.php:23`
- Modify: `.env.example`
- Test: `tests/Feature/ReleaseCheckerTest.php`
- [ ] **Step 1: Write the failing test** — append to `tests/Feature/ReleaseCheckerTest.php` (inside the class):
```php
public function test_no_update_check_when_no_repository_is_configured(): void
{
Http::fake(); // any network call would be a bug
config()->set('clusev.repository', '');
config()->set('clusev.version', '0.9.10');
Cache::flush();
$this->assertFalse(app(ReleaseChecker::class)->updateAvailable());
$this->assertNull(app(ReleaseChecker::class)->refresh('stable'));
Http::assertNothingSent();
}
```
- [ ] **Step 2: Run it — confirm it passes already OR fails meaningfully**
Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=test_no_update_check_when_no_repository_is_configured`
Expected: PASS (ReleaseChecker::fetchRemoteLatestTag already returns null when the repo URL doesn't match its regex — empty string never matches). If it FAILS, fix ReleaseChecker so an unparseable/empty `clusev.repository` returns null before any `Http::` call.
- [ ] **Step 3: Make the config env-driven** — in `config/clusev.php`, replace line 23:
```php
// Update source. Empty by default: the PUBLIC repo URL is injected via CLUSEV_REPOSITORY (env),
// which install.sh derives from the clone origin. Private (Gitea/GitHub-private) URLs live ONLY in
// the gitignored .env — never in tracked files, so the public repo exposes no private URL.
'repository' => env('CLUSEV_REPOSITORY', ''),
```
- [ ] **Step 4: Document it in `.env.example`** — add (near the other CLUSEV_* entries, or at the end):
```dotenv
# Update source for the in-app version check. install.sh derives this from the clone origin
# (git remote get-url origin). Leave empty to disable the check. Never put a private repo URL in a
# tracked file — only here in the per-deployment .env.
CLUSEV_REPOSITORY=
```
- [ ] **Step 5: Set it on THIS dev machine (runtime, NOT committed)** — append to the dev `.env` so the dev Versions page keeps checking Gitea:
Run: `grep -q '^CLUSEV_REPOSITORY=' /home/nexxo/clusev/.env && sed -i 's#^CLUSEV_REPOSITORY=.*#CLUSEV_REPOSITORY=https://git.bave.dev/boban/clusev#' /home/nexxo/clusev/.env || printf 'CLUSEV_REPOSITORY=https://git.bave.dev/boban/clusev\n' >> /home/nexxo/clusev/.env`
Then: `docker compose --project-directory /home/nexxo/clusev exec -u "1002:1002" -T app php artisan config:clear`
Expected: `.env` (gitignored) now pins the Gitea URL; config cache cleared.
- [ ] **Step 6: Run the suite**
Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleaseCheckerTest`
Expected: PASS (all ReleaseChecker tests incl. the new one).
- [ ] **Step 7: Commit**
```bash
git add config/clusev.php .env.example tests/Feature/ReleaseCheckerTest.php
git commit -m "refactor(config): repository URL is env-driven (no private URL in tracked files)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: Shared `set-repository-url.sh` — derive CLUSEV_REPOSITORY from the clone origin
**Files:**
- Create: `scripts/set-repository-url.sh`
- Create: `scripts/set-repository-url.test.sh`
- Modify: `install.sh`, `update.sh`
- [ ] **Step 1: Write the failing test** — create `scripts/set-repository-url.test.sh`:
```bash
#!/usr/bin/env bash
# Test: set-repository-url.sh writes CLUSEV_REPOSITORY (derived from the clone origin) into the .env,
# creating or replacing the line, and strips a trailing .git.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
git init -q "$work/proj"
git -C "$work/proj" remote add origin https://git.example.com/o/clusev.git
printf 'APP_ENV=local\n' > "$work/proj/.env"
bash "$here/set-repository-url.sh" "$work/proj" "$work/proj/.env"
grep -qx 'CLUSEV_REPOSITORY=https://git.example.com/o/clusev' "$work/proj/.env" || { echo "FAIL: append/strip-.git"; exit 1; }
# replace an existing line
printf 'CLUSEV_REPOSITORY=old\n' >> "$work/proj/.env"
git -C "$work/proj" remote set-url origin https://gitea.local/u/clusev
bash "$here/set-repository-url.sh" "$work/proj" "$work/proj/.env"
test "$(grep -c '^CLUSEV_REPOSITORY=' "$work/proj/.env")" = 1 || { echo "FAIL: duplicate line"; exit 1; }
grep -qx 'CLUSEV_REPOSITORY=https://gitea.local/u/clusev' "$work/proj/.env" || { echo "FAIL: replace"; exit 1; }
# no origin → no-op, no crash
git init -q "$work/noremote"; printf '' > "$work/noremote/.env"
bash "$here/set-repository-url.sh" "$work/noremote" "$work/noremote/.env"
grep -q 'CLUSEV_REPOSITORY' "$work/noremote/.env" && { echo "FAIL: wrote without origin"; exit 1; }
echo "PASS"
```
- [ ] **Step 2: Run it to verify it fails**
Run: `bash scripts/set-repository-url.test.sh`
Expected: FAIL (`set-repository-url.sh` does not exist yet → bash error).
- [ ] **Step 3: Write `scripts/set-repository-url.sh`:**
```bash
#!/usr/bin/env bash
# Derive the update source (CLUSEV_REPOSITORY) from the clone origin and write it into the given .env.
# Called by install.sh + update.sh. The private (Gitea/GitHub-private) URL therefore lives ONLY in the
# gitignored .env — never in a tracked file. No-op (exit 0) when there is no origin remote.
# usage: set-repository-url.sh <project-dir> <env-file>
set -euo pipefail
proj="${1:?project dir required}"
envfile="${2:?env file required}"
url="$(git -C "$proj" remote get-url origin 2>/dev/null || true)"
url="${url%.git}" # normalise — the API host is the same with or without .git
url="${url%/}" # drop a trailing slash
[ -n "$url" ] || exit 0 # no origin (e.g. tarball install) → leave .env untouched
if grep -q '^CLUSEV_REPOSITORY=' "$envfile" 2>/dev/null; then
sed -i "s#^CLUSEV_REPOSITORY=.*#CLUSEV_REPOSITORY=${url}#" "$envfile"
else
printf 'CLUSEV_REPOSITORY=%s\n' "$url" >> "$envfile"
fi
```
- [ ] **Step 4: Run the test + shellcheck**
Run: `chmod +x scripts/set-repository-url.sh && bash scripts/set-repository-url.test.sh`
Expected: `PASS`
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/set-repository-url.sh scripts/set-repository-url.test.sh`
Expected: no output (clean).
- [ ] **Step 5: Wire it into `install.sh`** — find where the `.env` is finalised (search for where secrets are generated / `.env` is written; near the app-key/secret generation). Add AFTER the `.env` exists, BEFORE the stack starts:
```bash
# Pin the update source to wherever this was cloned from (keeps private URLs out of tracked files).
[ -x "${proj}/scripts/set-repository-url.sh" ] && "${proj}/scripts/set-repository-url.sh" "$proj" "${proj}/.env" || true
```
(Use the variable install.sh already holds for the project root — it is `proj="$(pwd)"` in `install_host_watchers`; at the top level it is the script's CWD. Use the same project-root variable the surrounding code uses; if none, `"$(pwd)"`.)
- [ ] **Step 6: Wire it into `update.sh`** — after the `git pull` / before/after re-running install, add the same call so the URL is refreshed if the remote changed:
```bash
[ -x ./scripts/set-repository-url.sh ] && ./scripts/set-repository-url.sh "$(pwd)" ./.env || true
```
- [ ] **Step 7: shellcheck install.sh + update.sh**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable install.sh update.sh`
Expected: no NEW findings from the added lines (clean, or pre-existing only).
- [ ] **Step 8: Commit**
```bash
git add scripts/set-repository-url.sh scripts/set-repository-url.test.sh install.sh update.sh
git commit -m "feat(install): derive CLUSEV_REPOSITORY from the clone origin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 3: `promote.sh` — copy a tag's tree into the public repo (clean release commit)
**Files:**
- Create: `scripts/promote.sh`
- Create: `scripts/promote.test.sh`
- [ ] **Step 1: Write the failing test** — create `scripts/promote.test.sh`:
```bash
#!/usr/bin/env bash
# Test: promote.sh extracts a tag's tree into the public repo working dir (excluding .git + .github),
# commits "Release <tag>" and creates the tag. The public repo's own .git is preserved.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
# source repo with a tag, a .github dir and an .env.example
git init -q "$work/src"
mkdir -p "$work/src/.github/workflows"
printf 'app\n' > "$work/src/app.txt"
printf 'CLUSEV_REPOSITORY=\n' > "$work/src/.env.example"
printf 'name: ci\n' > "$work/src/.github/workflows/ci.yml"
git -C "$work/src" add -A && git -C "$work/src" commit -qm init && git -C "$work/src" tag v1.0.0
# empty public repo (its own history)
git init -q "$work/pub"
bash "$here/promote.sh" "$work/src" v1.0.0 "$work/pub" "Release v1.0.0"
test -f "$work/pub/app.txt" || { echo "FAIL: tree not copied"; exit 1; }
test -f "$work/pub/.env.example" || { echo "FAIL: .env.example missing"; exit 1; }
test ! -d "$work/pub/.github" || { echo "FAIL: .github leaked to public"; exit 1; }
test -d "$work/pub/.git" || { echo "FAIL: public .git clobbered"; exit 1; }
git -C "$work/pub" rev-parse v1.0.0 >/dev/null 2>&1 || { echo "FAIL: tag missing"; exit 1; }
git -C "$work/pub" log -1 --pretty=%s | grep -qx 'Release v1.0.0' || { echo "FAIL: commit message"; exit 1; }
echo "PASS"
```
- [ ] **Step 2: Run it to verify it fails**
Run: `bash scripts/promote.test.sh`
Expected: FAIL (`promote.sh` missing).
- [ ] **Step 3: Write `scripts/promote.sh`:**
```bash
#!/usr/bin/env bash
# Promote a tag's tree from the source repo into the PUBLIC repo working dir as one clean release
# commit + tag. Excludes .git (via git archive) and .github (private CI stays private). The public
# repo's own .git is preserved. The PUSH is done by the calling workflow (it holds the credentials).
# usage: promote.sh <src-repo> <tag> <public-repo-dir> <commit-message>
set -euo pipefail
src="${1:?src repo required}"
tag="${2:?tag required}"
pub="${3:?public repo dir required}"
msg="${4:?commit message required}"
git -C "$src" rev-parse "$tag" >/dev/null 2>&1 || { echo "unknown tag: $tag" >&2; exit 1; }
# Clear the public working tree but KEEP its .git.
find "$pub" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
# Extract exactly the tag's tree (git archive omits .git automatically).
git -C "$src" archive --format=tar "$tag" | tar -x -C "$pub"
# Private CI never goes public.
rm -rf "${pub:?}/.github"
git -C "$pub" add -A
git -C "$pub" commit -q -m "$msg"
git -C "$pub" tag "$tag"
```
- [ ] **Step 4: Run the test + shellcheck**
Run: `chmod +x scripts/promote.sh && bash scripts/promote.test.sh`
Expected: `PASS`
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/promote.sh scripts/promote.test.sh`
Expected: clean.
- [ ] **Step 5: Commit**
```bash
git add scripts/promote.sh scripts/promote.test.sh
git commit -m "feat(release): promote.sh — clean per-release tree copy to the public repo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 4: CI workflow — tests on every `v*` tag, optional staging deploy on betas
**Files:**
- Create: `.github/workflows/ci-staging.yml`
> Note: GitHub-hosted runners cannot reach an internal staging host (.165 has no public IP). The
> staging-deploy job is therefore GATED behind a repo variable `STAGING_ENABLED == 'true'` and meant
> for a **self-hosted runner** on the internal network. Until then it is a no-op and staging is updated
> via the Versions page (self-pull). Tests always run.
- [ ] **Step 1: Write `.github/workflows/ci-staging.yml`:**
```yaml
name: CI + staging
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --no-interaction --prefer-dist --no-progress
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test
- run: ./vendor/bin/pint --test
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
- 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
deploy-staging:
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: [self-hosted, clusev-staging]
environment: staging
steps:
- name: Update the staging stack
run: |
cd "${{ vars.STAGING_PROJECT_DIR }}"
sudo ./update.sh
```
- [ ] **Step 2: Lint with actionlint**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt rhysd/actionlint:latest -color`
Expected: no errors for `ci-staging.yml`. (Fix any reported syntax issues; the workflow must lint clean even though the secrets/vars/runner do not exist yet.)
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/ci-staging.yml
git commit -m "ci: test on v* tags, optional self-hosted staging deploy on betas
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 5: Promotion workflows — promote to public (beta) + promote stable
**Files:**
- Create: `.github/workflows/promote-public.yml`
- Create: `.github/workflows/promote-stable.yml`
> These are `workflow_dispatch` (manually / dashboard-triggered), bound to the `production`
> Environment (add a required reviewer in GitHub settings). They check out THIS (private) repo at the
> tag, then push a clean release into the public repo via `scripts/promote.sh`. `PUBLIC_REPO_URL` is a
> repo variable; `PUBLIC_REPO_TOKEN` a secret (a PAT/deploy token with push to the public repo). Both
> are added when the public repo exists — the workflow lints fine without them.
- [ ] **Step 1: Write `.github/workflows/promote-public.yml`:**
```yaml
name: Promote to public (beta channel)
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g. v0.10.0-beta1)'
required: true
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout private repo at the tag
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
path: src
- name: Checkout public repo
uses: actions/checkout@v4
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Promote (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.tag }}" "$PWD/pub" "Release ${{ inputs.tag }}"
- name: Push to public
run: |
git -C pub push origin HEAD:main
git -C pub push origin "refs/tags/${{ inputs.tag }}"
```
- [ ] **Step 2: Write `.github/workflows/promote-stable.yml`:**
```yaml
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@v4
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@v4
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 }}"
```
- [ ] **Step 3: Lint with actionlint**
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt rhysd/actionlint:latest -color`
Expected: no errors for either workflow.
- [ ] **Step 4: Commit**
```bash
git add .github/workflows/promote-public.yml .github/workflows/promote-stable.yml
git commit -m "ci: workflow_dispatch promotion to public (beta) + beta-to-stable
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 6: Document the flow + the wiring points
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Add a "Release pipeline" section to `README.md`** (near the Updating section). Use this exact prose:
```markdown
## Release pipeline (maintainers)
Clusev is promoted across three repositories, dev → staging → public:
1. **Gitea (private)** — sole development source. Commit + tag here.
2. **GitHub private** — a push-mirror of Gitea (Gitea → Settings → Mirror). Runs CI; betas are tested
on the staging server.
3. **GitHub public** — receives only released versions: one clean commit per release, beta and stable
tags. Public users install/update from here.
Channels follow semver tags: **beta** = `vX.Y.Z-betaN`, **stable** = `vX.Y.Z`.
No private repo URL is committed: `config/clusev.php` defaults `repository` to empty, `install.sh`
derives `CLUSEV_REPOSITORY` from the clone origin into the (gitignored) `.env`, so dev points at Gitea,
staging at GitHub-private, and public users at GitHub-public — automatically.
### One-time wiring (when the GitHub repos exist)
- Gitea push-mirror → the GitHub-private URL + a GitHub PAT.
- GitHub-private repo settings:
- Variable `PUBLIC_REPO_SLUG` = `owner/repo` of the public repo; `PUBLIC_REPO_URL` if needed.
- Secret `PUBLIC_REPO_TOKEN` = a token with push to the public repo.
- (Optional staging) Variable `STAGING_ENABLED=true`, `STAGING_PROJECT_DIR`, and a self-hosted
runner labelled `clusev-staging` on the internal network (GitHub-hosted runners cannot reach an
internal staging host).
- Environment `production` with a required reviewer (gates the promotion workflows).
- Public repo's clone command goes in its own README; set `CLUSEV_REPOSITORY` default in
`config/clusev.php` to the public URL once known.
### Promoting
- **To staging:** push a beta tag `vX.Y.Z-betaN` (CI tests; staging deploys if enabled, else update it
from the Versions page).
- **To public (beta):** run the `Promote to public (beta channel)` workflow with the beta tag.
- **To stable:** run the `Promote beta to stable` workflow with the beta tag + the stable tag.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: release pipeline (3-repo promotion, channels, wiring)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 7: Version bump + changelog + full verification
**Files:**
- Modify: `config/clusev.php` (version)
- Modify: `CHANGELOG.md`
- [ ] **Step 1: Run the whole suite + shellcheck + actionlint once more**
Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test`
Expected: all pass.
Run: `bash scripts/set-repository-url.test.sh && bash scripts/promote.test.sh`
Expected: `PASS` / `PASS`.
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh install.sh update.sh && docker run --rm -v "$PWD:/mnt" -w /mnt rhysd/actionlint:latest -color`
Expected: clean.
- [ ] **Step 2: Bump version** — in `config/clusev.php`, set `'version'` to the next patch (e.g. `0.9.57`).
- [ ] **Step 3: Changelog** — add a `## [0.9.57] - <date>` entry under `## [Unreleased]` describing the env-driven repository URL + the CI/promotion scaffolding (URL-agnostic; activated when the GitHub repos exist).
- [ ] **Step 4: Commit + tag + push (token-sanitized)**
```bash
git add config/clusev.php CHANGELOG.md
git commit -m "chore: release 0.9.57 — release-promotion phase A (CI/CD scaffolding)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
git tag -a v0.9.57 -m "v0.9.57 — release-promotion phase A"
set -a; . /home/nexxo/.env.gitea; set +a
TOKEN="${GIT_ACCESS_TOKEN:-${GITEA_TOKEN:-}}"
URL="https://oauth2:${TOKEN}@git.bave.dev/boban/clusev.git"
{ git push "$URL" feat/v1-foundation 2>&1; git push "$URL" v0.9.57 2>&1; } | sed -E "s#${TOKEN}#***#g; s#//[^@]*@#//***@#g"
```
---
## Self-Review
**Spec coverage:**
- URL handling (public default + derive from origin + .env-only private) → Task 1 + Task 2. ✓
- CI on tags + staging deploy → Task 4. ✓
- promote-public + promote-stable workflows → Task 5. ✓
- promote.sh clean tree copy → Task 3. ✓
- ReleaseChecker degradation → Task 1. ✓
- README docs → Task 6. ✓
- Phase B (dashboard buttons) → explicitly out of scope (own spec). ✓
**Placeholder scan:** every step has concrete code/commands. The GitHub secrets/vars/URLs are genuinely deferred config (documented in Task 6), not code placeholders — the workflows reference them via `${{ vars.* }}` / `${{ secrets.* }}` and lint clean without them. ✓
**Type/name consistency:** `scripts/set-repository-url.sh`, `scripts/promote.sh` (and `.test.sh` siblings) referenced identically across tasks; `CLUSEV_REPOSITORY`, `PUBLIC_REPO_TOKEN`, `PUBLIC_REPO_SLUG`, `STAGING_ENABLED` consistent throughout. ✓