docs(plan): release-promotion phase A implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
30034a564b
commit
06b5600641
|
|
@ -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. ✓
|
||||
Loading…
Reference in New Issue