From 06b56006417b5d39e6b11c11aa8552c127a501b4 Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 22 Jun 2026 20:33:03 +0200 Subject: [PATCH] docs(plan): release-promotion phase A implementation plan Co-Authored-By: Claude Opus 4.8 --- .../2026-06-22-release-promotion-phase-a.md | 601 ++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-release-promotion-phase-a.md diff --git a/docs/superpowers/plans/2026-06-22-release-promotion-phase-a.md b/docs/superpowers/plans/2026-06-22-release-promotion-phase-a.md new file mode 100644 index 0000000..0d3684b --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-release-promotion-phase-a.md @@ -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 `; commit messages in English, end with the `Co-Authored-By: Claude Opus 4.8 ` 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 " +``` + +--- + +### 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 +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 " +``` + +--- + +### 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 " 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 +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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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] - ` 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 " +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. ✓