refactor(release): single stable channel — cut internal release candidates (-rc), remove all beta wording
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
e6f6e7f8a4
commit
f804e25a66
|
|
@ -105,7 +105,7 @@ jobs:
|
||||||
# Gitea-free code tree; promote.sh's leak-guard is the last line of defence.
|
# Gitea-free code tree; promote.sh's leak-guard is the last line of defence.
|
||||||
promote:
|
promote:
|
||||||
needs: [test, images]
|
needs: [test, images]
|
||||||
# Stable = a clean vX.Y.Z with NO pre-release suffix. Excludes -rc AND any accidental -beta/-alpha.
|
# Stable = a clean vX.Y.Z with NO pre-release suffix. Excludes -rc AND any accidental pre-release suffix.
|
||||||
if: ${{ success() && startsWith(github.ref_name, 'v') && !contains(github.ref_name, '-') }}
|
if: ${{ success() && startsWith(github.ref_name, 'v') && !contains(github.ref_name, '-') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: 'Tag to retract from the public repo (e.g. v0.10.0 or v0.10.0-beta1)'
|
description: 'Tag to retract from the public repo (e.g. v0.10.0)'
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -18,7 +18,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
TAG: ${{ inputs.tag }}
|
TAG: ${{ inputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-beta[0-9]+)?$' \
|
echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||||
|| { echo "refusing to yank an unrecognised tag: $TAG" >&2; exit 1; }
|
|| { echo "refusing to yank an unrecognised tag: $TAG" >&2; exit 1; }
|
||||||
- name: Checkout public repo
|
- name: Checkout public repo
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
|
||||||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -7,7 +7,7 @@ Alle nennenswerten Änderungen an Clusev. Format nach
|
||||||
**Release-Prozess:** Feature-Commits sammeln sich auf dem Branch. Ist ein Stand
|
**Release-Prozess:** Feature-Commits sammeln sich auf dem Branch. Ist ein Stand
|
||||||
reif, wird er nach `main` gemerged, als `vX.Y.Z` getaggt und hier unter einer
|
reif, wird er nach `main` gemerged, als `vX.Y.Z` getaggt und hier unter einer
|
||||||
eigenen Versionsüberschrift dokumentiert. Nutzer beziehen ausschließlich
|
eigenen Versionsüberschrift dokumentiert. Nutzer beziehen ausschließlich
|
||||||
getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Builds.
|
getaggte Releases (Kanal `stable`) — niemals Entwicklungs-Builds.
|
||||||
|
|
||||||
**Was hier steht:** nur, was Nutzer der Installation merken — knapp, ein Eintrag
|
**Was hier steht:** nur, was Nutzer der Installation merken — knapp, ein Eintrag
|
||||||
pro Änderung, gruppiert nach _Hinzugefügt / Geändert / Behoben / Sicherheit_. Rein
|
pro Änderung, gruppiert nach _Hinzugefügt / Geändert / Behoben / Sicherheit_. Rein
|
||||||
|
|
@ -18,7 +18,13 @@ sondern in die git-Historie.
|
||||||
|
|
||||||
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||||
|
|
||||||
## [0.11.0-beta1] - 2026-06-26
|
## [0.11.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- **Ein einziger stabiler Release-Kanal.** Nutzer erhalten ausschließlich finalisierte `vX.Y.Z`-Releases;
|
||||||
|
die interne Release-Steuerung schneidet jetzt Release Candidates statt Vorab-Kanälen.
|
||||||
|
|
||||||
|
## [0.11.0-rc1] - 2026-06-26
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
- **Web-Terminal.** Vollwertiges SSH-Terminal direkt im Browser — eines pro Server und eines für den
|
- **Web-Terminal.** Vollwertiges SSH-Terminal direkt im Browser — eines pro Server und eines für den
|
||||||
|
|
@ -47,7 +53,7 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
|
||||||
- **Proxy-Vertrauen gehärtet:** nur noch private Netze werden vertraut, damit ein gefälschter
|
- **Proxy-Vertrauen gehärtet:** nur noch private Netze werden vertraut, damit ein gefälschter
|
||||||
`X-Forwarded-For` die IP-basierten Brute-Force-Limits nicht mehr umgehen kann.
|
`X-Forwarded-For` die IP-basierten Brute-Force-Limits nicht mehr umgehen kann.
|
||||||
|
|
||||||
## [0.10.0-beta1] - 2026-06-25
|
## [0.10.0-rc1] - 2026-06-25
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
- **Verlaufs-Graf auf der Server-Detailseite.** CPU, RAM und Disk über Zeit — Zeitraum wählbar
|
- **Verlaufs-Graf auf der Server-Detailseite.** CPU, RAM und Disk über Zeit — Zeitraum wählbar
|
||||||
|
|
@ -64,7 +70,7 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
|
||||||
## [0.9.68] - 2026-06-23
|
## [0.9.68] - 2026-06-23
|
||||||
|
|
||||||
### Intern
|
### Intern
|
||||||
- **Release-Tooling im Dev-Dashboard + CI-Pipeline.** Beta schneiden, Live-Pipeline-Status,
|
- **Release-Tooling im Dev-Dashboard + CI-Pipeline.** Release Candidate schneiden, Live-Pipeline-Status,
|
||||||
Veröffentlichen / Promote-to-Stable / Yank — für Nutzer der Installation nicht sichtbar.
|
Veröffentlichen / Promote-to-Stable / Yank — für Nutzer der Installation nicht sichtbar.
|
||||||
Details in der git-Historie. (Sammelt die internen Versionen 0.9.59–0.9.68.)
|
Details in der git-Historie. (Sammelt die internen Versionen 0.9.59–0.9.68.)
|
||||||
|
|
||||||
|
|
@ -81,8 +87,8 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
|
||||||
## [0.9.57] - 2026-06-22
|
## [0.9.57] - 2026-06-22
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
- **Release-Kanäle `stable` und `beta`.** Nutzer beziehen stabile Releases (`vX.Y.Z`); wer früher
|
- **Stabiler Release-Kanal `stable`.** Nutzer beziehen ausschließlich stabile Releases (`vX.Y.Z`) —
|
||||||
testen will, wählt den Beta-Kanal (`vX.Y.Z-betaN`) — niemals Entwicklungs-Builds.
|
niemals Entwicklungs-Builds.
|
||||||
|
|
||||||
## [0.9.56] - 2026-06-22
|
## [0.9.56] - 2026-06-22
|
||||||
|
|
||||||
|
|
@ -725,7 +731,7 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
|
||||||
Version veröffentlicht war. Der neueste Tag im Kanal wird jetzt über die **öffentliche, lesende
|
Version veröffentlicht war. Der neueste Tag im Kanal wird jetzt über die **öffentliche, lesende
|
||||||
Tag-API des Git-Hosts** abgefragt (anonym — kein Token verlässt das Panel), mit lokalem `.git` nur
|
Tag-API des Git-Hosts** abgefragt (anonym — kein Token verlässt das Panel), mit lokalem `.git` nur
|
||||||
noch als Dev-Fallback; das Ergebnis ist gecacht, sodass ein Seitenaufruf nie eine Netzwerkanfrage
|
noch als Dev-Fallback; das Ergebnis ist gecacht, sodass ein Seitenaufruf nie eine Netzwerkanfrage
|
||||||
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet nur `vX.Y.Z`, `beta` auch Vorab-Tags.
|
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet ausschließlich `vX.Y.Z` an.
|
||||||
Schlägt die Abfrage fehl (offline o. Ä.), degradiert sie still statt zu blockieren.
|
Schlägt die Abfrage fehl (offline o. Ä.), degradiert sie still statt zu blockieren.
|
||||||
|
|
||||||
## [0.9.6] - 2026-06-19
|
## [0.9.6] - 2026-06-19
|
||||||
|
|
@ -1295,7 +1301,7 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane
|
||||||
- Panel-Selbst-Härtung: Security-Header + umgebungsbewusste CSP, 2FA-Brute-Force-
|
- Panel-Selbst-Härtung: Security-Header + umgebungsbewusste CSP, 2FA-Brute-Force-
|
||||||
Drossel, Session-Cookie-Härtung.
|
Drossel, Session-Cookie-Härtung.
|
||||||
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
|
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
|
||||||
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
|
pro Version, Kanal `stable` (kein nutzer-seitiges `dev`).
|
||||||
|
|
||||||
[Unreleased]: https://github.com/clusev/clusev/compare/v0.2.1...HEAD
|
[Unreleased]: https://github.com/clusev/clusev/compare/v0.2.1...HEAD
|
||||||
[0.2.1]: https://github.com/clusev/clusev/compare/v0.2.0...v0.2.1
|
[0.2.1]: https://github.com/clusev/clusev/compare/v0.2.0...v0.2.1
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only Release control. "Deploy to Staging" cuts a beta of an allowed target version via the
|
* Dev-only Release control. "Deploy to Staging" cuts a release candidate of an allowed target
|
||||||
* host release bridge (bump → commit → tag → push to Gitea). The page is reachable only when
|
* version via the host release bridge (bump → commit → tag → push to Gitea). The page is reachable only when
|
||||||
* config('clusev.release_controls') is true; mount() re-guards as defence-in-depth.
|
* config('clusev.release_controls') is true; mount() re-guards as defence-in-depth.
|
||||||
*
|
*
|
||||||
* Deploy-to-Staging is irreversible (it pushes a tag the host CI acts on), so it is gated behind the
|
* Deploy-to-Staging is irreversible (it pushes a tag the host CI acts on), so it is gated behind the
|
||||||
|
|
@ -37,7 +37,7 @@ class Index extends Component
|
||||||
/** The target X.Y.Z being staged (for display while it runs). */
|
/** The target X.Y.Z being staged (for display while it runs). */
|
||||||
public ?string $pendingTarget = null;
|
public ?string $pendingTarget = null;
|
||||||
|
|
||||||
/** The tag of the last successfully-pushed staging beta (shown until the next deploy). */
|
/** The tag of the last successfully-pushed staging release candidate (shown until the next deploy). */
|
||||||
public ?string $lastTag = null;
|
public ?string $lastTag = null;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
|
|
@ -46,7 +46,7 @@ class Index extends Component
|
||||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging beta of $target. */
|
/** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging release candidate of $target. */
|
||||||
public function confirmDeployStaging(string $target, ReleasePlanner $planner): void
|
public function confirmDeployStaging(string $target, ReleasePlanner $planner): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
|
|
@ -80,7 +80,7 @@ class Index extends Component
|
||||||
$this->deployStaging($target, $planner, $bridge);
|
$this->deployStaging($target, $planner, $bridge);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cut + push a beta of $target via the host bridge. $target must be one of the proposed values. */
|
/** Cut + push a release candidate of $target via the host bridge. $target must be one of the proposed values. */
|
||||||
public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void
|
public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
|
|
@ -160,12 +160,12 @@ class Index extends Component
|
||||||
$pipeline->refresh();
|
$pipeline->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The current in-flight beta tag (vX.Y.Z-betaN), or null when the running version is stable. */
|
/** The current in-flight release-candidate tag (vX.Y.Z-rcN), or null when the running version is stable. */
|
||||||
private function currentBetaTag(): ?string
|
private function currentRcTag(): ?string
|
||||||
{
|
{
|
||||||
$v = (string) config('clusev.version');
|
$v = (string) config('clusev.version');
|
||||||
|
|
||||||
return str_contains($v, '-beta') ? 'v'.ltrim($v, 'vV') : null;
|
return str_contains($v, '-rc') ? 'v'.ltrim($v, 'vV') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-user throttle shared by all promotion dispatches (auto-expiring, never a lockout). */
|
/** Per-user throttle shared by all promotion dispatches (auto-expiring, never a lockout). */
|
||||||
|
|
@ -197,7 +197,7 @@ class Index extends Component
|
||||||
public function confirmDeployPublic(): void
|
public function confirmDeployPublic(): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$tag = $this->currentBetaTag();
|
$tag = $this->currentRcTag();
|
||||||
if ($tag === null) {
|
if ($tag === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +216,7 @@ class Index extends Component
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$tag = (string) ($payload['params']['tag'] ?? '');
|
$tag = (string) ($payload['params']['tag'] ?? '');
|
||||||
if ($tag === '' || $this->currentBetaTag() !== $tag || ! $this->throttlePromote()) {
|
if ($tag === '' || $this->currentRcTag() !== $tag || ! $this->throttlePromote()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$ok = $promotion->deployPublic($tag);
|
$ok = $promotion->deployPublic($tag);
|
||||||
|
|
@ -288,7 +288,7 @@ class Index extends Component
|
||||||
return view('livewire.release.index', [
|
return view('livewire.release.index', [
|
||||||
'current' => $current,
|
'current' => $current,
|
||||||
'targets' => app(ReleasePlanner::class)->proposedTargets($current),
|
'targets' => app(ReleasePlanner::class)->proposedTargets($current),
|
||||||
'pipeline' => app(PipelineStatus::class)->forTrackedBeta(),
|
'pipeline' => app(PipelineStatus::class)->forTrackedRc(),
|
||||||
'publicTags' => app(PromotionService::class)->publicTags(),
|
'publicTags' => app(PromotionService::class)->publicTags(),
|
||||||
])->title(__('release.title'));
|
])->title(__('release.title'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live status of the build pipeline for the currently-cut beta, for the dev Release page: tag (Gitea),
|
* Live status of the build pipeline for the currently-cut release candidate, for the dev Release page: tag (Gitea),
|
||||||
* mirror (GitHub-private), CI (GitHub Actions). These are global build facts of one beta — there is no
|
* mirror (GitHub-private), CI (GitHub Actions). These are global build facts of one release candidate — there is no
|
||||||
* "deployed" step because any beta-channel server pulls a release itself (no single staging target).
|
* "deployed" step because any server tracking the candidate pulls a release itself (no single staging target).
|
||||||
* Each step is cached briefly; any failure degrades to 'unknown' (never throws). The read-only GitHub
|
* Each step is cached briefly; any failure degrades to 'unknown' (never throws). The read-only GitHub
|
||||||
* token and the private staging slug come from config (env) and are never rendered or put in a URL.
|
* token and the private staging slug come from config (env) and are never rendered or put in a URL.
|
||||||
*/
|
*/
|
||||||
|
|
@ -20,10 +20,10 @@ class PipelineStatus
|
||||||
/**
|
/**
|
||||||
* @return array{tag:string, steps:array<int,array{key:string,state:string,detail:?string,url:?string}>, ciRunning:bool}|null
|
* @return array{tag:string, steps:array<int,array{key:string,state:string,detail:?string,url:?string}>, ciRunning:bool}|null
|
||||||
*/
|
*/
|
||||||
public function forTrackedBeta(): ?array
|
public function forTrackedRc(): ?array
|
||||||
{
|
{
|
||||||
$version = (string) config('clusev.version');
|
$version = (string) config('clusev.version');
|
||||||
if (! str_contains($version, '-beta')) {
|
if (! str_contains($version, '-rc')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$tag = 'v'.ltrim($version, 'vV');
|
$tag = 'v'.ltrim($version, 'vV');
|
||||||
|
|
@ -47,7 +47,7 @@ class PipelineStatus
|
||||||
public function refresh(): void
|
public function refresh(): void
|
||||||
{
|
{
|
||||||
$version = (string) config('clusev.version');
|
$version = (string) config('clusev.version');
|
||||||
if (! str_contains($version, '-beta')) {
|
if (! str_contains($version, '-rc')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$tag = 'v'.ltrim($version, 'vV');
|
$tag = 'v'.ltrim($version, 'vV');
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ use Illuminate\Support\Facades\Http;
|
||||||
*/
|
*/
|
||||||
class PromotionService
|
class PromotionService
|
||||||
{
|
{
|
||||||
public function deployPublic(string $betaTag): bool
|
public function deployPublic(string $rcTag): bool
|
||||||
{
|
{
|
||||||
return $this->dispatch('promote-public.yml', ['tag' => $betaTag]);
|
return $this->dispatch('promote-public.yml', ['tag' => $rcTag]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function yank(string $tag): bool
|
public function yank(string $tag): bool
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,16 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure semver arithmetic for the Release page: given the current version, what beta-target versions
|
* Pure semver arithmetic for the Release page: given the current version, what rc-target versions
|
||||||
* may the operator cut? The dashboard computes the target X.Y.Z; the host bridge computes -betaN.
|
* may the operator cut? The dashboard computes the target X.Y.Z; the host bridge computes -rcN.
|
||||||
*/
|
*/
|
||||||
class ReleasePlanner
|
class ReleasePlanner
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Proposed target versions (bare X.Y.Z) for the current version. A beta also offers continuing
|
* Proposed target versions (bare X.Y.Z) for the current version. A release candidate also offers
|
||||||
* its own base (another beta of the same target).
|
* continuing its own base (another release candidate of the same target).
|
||||||
*
|
*
|
||||||
* @return array{patch:string, minor:string, major:string, continueBeta?:string}
|
* @return array{patch:string, minor:string, major:string, continueRc?:string}
|
||||||
*/
|
*/
|
||||||
public function proposedTargets(string $current): array
|
public function proposedTargets(string $current): array
|
||||||
{
|
{
|
||||||
|
|
@ -26,8 +26,8 @@ class ReleasePlanner
|
||||||
'major' => ($maj + 1).'.0.0',
|
'major' => ($maj + 1).'.0.0',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (str_contains($current, '-beta')) {
|
if (str_contains($current, '-rc')) {
|
||||||
$targets['continueBeta'] = "{$maj}.{$min}.{$pat}";
|
$targets['continueRc'] = "{$maj}.{$min}.{$pat}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return $targets;
|
return $targets;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
// First tagged release is v0.1.0 (semantic, not -dev).
|
||||||
'version' => '0.11.0-beta1',
|
'version' => '0.11.0',
|
||||||
|
|
||||||
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
||||||
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||||
|
|
@ -12,7 +12,7 @@ return [
|
||||||
'branch' => env('CLUSEV_BUILD_BRANCH') ?: null,
|
'branch' => env('CLUSEV_BUILD_BRANCH') ?: null,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
|
// Single stable release channel — users only ever receive finalized vX.Y.Z releases.
|
||||||
'channel' => 'stable',
|
'channel' => 'stable',
|
||||||
|
|
||||||
// Install-time panel domain (APP_DOMAIN). The dashboard Setting overrides it; this
|
// Install-time panel domain (APP_DOMAIN). The dashboard Setting overrides it; this
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# Release bridge (host watcher) — DEV ONLY
|
# Release bridge (host watcher) — DEV ONLY
|
||||||
|
|
||||||
The dashboard "Release" page (Deploy to Staging) cuts a beta by asking the **host** to do the git
|
The dashboard "Release" page (Deploy to Staging) cuts a release candidate by asking the **host** to do the git
|
||||||
work, so the container never holds git credentials — the same isolation as the WireGuard bridge.
|
work, so the container never holds git credentials — the same isolation as the WireGuard bridge.
|
||||||
|
|
||||||
- `clusev-release.sh serve-request` — reads `run/release-request.json` (the dashboard wrote it),
|
- `clusev-release.sh serve-request` — reads `run/release-request.json` (the dashboard wrote it),
|
||||||
re-validates, bumps `config/clusev.php`, commits, tags `vX.Y.Z-betaN`, pushes to Gitea (token read
|
re-validates, bumps `config/clusev.php`, commits, tags `vX.Y.Z-rcN`, pushes to Gitea (token read
|
||||||
host-side from `/home/nexxo/.env.gitea`), and writes `run/release-result-<id>.json` for the
|
host-side from `/home/nexxo/.env.gitea`), and writes `run/release-result-<id>.json` for the
|
||||||
dashboard to poll. Roll back leaves no half-release if the push fails.
|
dashboard to poll. Roll back leaves no half-release if the push fails.
|
||||||
- `clusev-release-request.path` / `.service` — a systemd `.path` watcher that runs the handler when a
|
- `clusev-release-request.path` / `.service` — a systemd `.path` watcher that runs the handler when a
|
||||||
|
|
@ -30,5 +30,5 @@ lands where the watcher looks.
|
||||||
## Verify (manual gate)
|
## Verify (manual gate)
|
||||||
|
|
||||||
This is the end-to-end gate (like the WireGuard runbook): with the watcher enabled and the flag on,
|
This is the end-to-end gate (like the WireGuard runbook): with the watcher enabled and the flag on,
|
||||||
open `/release`, click a bump button, confirm, and check that `vX.Y.Z-betaN` lands on Gitea (and, via
|
open `/release`, click a bump button, confirm, and check that `vX.Y.Z-rcN` lands on Gitea (and, via
|
||||||
the push-mirror, on GitHub-private). The handler never logs the Gitea token.
|
the push-mirror, on GitHub-private). The handler never logs the Gitea token.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Host side of the dashboard release bridge. `serve-request` reads run/release-request.json (the
|
# Host side of the dashboard release bridge. `serve-request` reads run/release-request.json (the
|
||||||
# dashboard wrote it), cuts a beta — bump config version, commit, tag vX.Y.Z-betaN, push to Gitea —
|
# dashboard wrote it), cuts a release candidate — bump config version, commit, tag vX.Y.Z-rcN, push to Gitea —
|
||||||
# and writes a result the dashboard polls. ALL git/token operations run here; the container never
|
# and writes a result the dashboard polls. ALL git/token operations run here; the container never
|
||||||
# holds credentials. `stage <target>` is the same path without the request file (used by the test).
|
# holds credentials. `stage <target>` is the same path without the request file (used by the test).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -43,7 +43,7 @@ _precheck() {
|
||||||
[ "$(git -C "$PROJ" rev-parse HEAD)" = "$(git -C "$PROJ" rev-parse "origin/${BRANCH}")" ] || { echo "unpushed"; return 1; }
|
[ "$(git -C "$PROJ" rev-parse HEAD)" = "$(git -C "$PROJ" rev-parse "origin/${BRANCH}")" ] || { echo "unpushed"; return 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cut + push a beta of <target>. Echoes the new tag on success; reason on stderr + non-zero on failure.
|
# Cut + push a release candidate of <target>. Echoes the new tag on success; reason on stderr + non-zero on failure.
|
||||||
_do_stage() { # target
|
_do_stage() { # target
|
||||||
local target="$1" cur base
|
local target="$1" cur base
|
||||||
cur="$(grep -oE "'version' => '[^']*'" "${PROJ}/config/clusev.php" | head -n1 | sed -E "s/.*=> '//; s/'$//" || true)"
|
cur="$(grep -oE "'version' => '[^']*'" "${PROJ}/config/clusev.php" | head -n1 | sed -E "s/.*=> '//; s/'$//" || true)"
|
||||||
|
|
@ -51,11 +51,11 @@ _do_stage() { # target
|
||||||
_ver_ge "$target" "$base" || { echo "downgrade-rejected" >&2; return 1; }
|
_ver_ge "$target" "$base" || { echo "downgrade-rejected" >&2; return 1; }
|
||||||
|
|
||||||
local n=0 t k
|
local n=0 t k
|
||||||
for t in $(git -C "$PROJ" tag -l "v${target}-beta*"); do
|
for t in $(git -C "$PROJ" tag -l "v${target}-rc*"); do
|
||||||
k="${t##*-beta}"
|
k="${t##*-rc}"
|
||||||
case "$k" in ''|*[!0-9]*) ;; *) [ "$k" -gt "$n" ] && n="$k" ;; esac
|
case "$k" in ''|*[!0-9]*) ;; *) [ "$k" -gt "$n" ] && n="$k" ;; esac
|
||||||
done
|
done
|
||||||
local ver="${target}-beta$((n + 1))" newtag="v${target}-beta$((n + 1))"
|
local ver="${target}-rc$((n + 1))" newtag="v${target}-rc$((n + 1))"
|
||||||
|
|
||||||
sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php"
|
sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php"
|
||||||
git -C "$PROJ" commit -q -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; }
|
git -C "$PROJ" commit -q -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Test clusev-release.sh `stage`: bump config version, commit, tag vX.Y.Z-betaN, push to a (local)
|
# Test clusev-release.sh `stage`: bump config version, commit, tag vX.Y.Z-rcN, push to a (local)
|
||||||
# Gitea-stand-in remote; beta auto-increment; refusals (dirty / unpushed / downgrade); push-fail rollback.
|
# Gitea-stand-in remote; rc auto-increment; refusals (dirty / unpushed / downgrade); push-fail rollback.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
here="$(cd "$(dirname "$0")" && pwd)"
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
|
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT
|
||||||
|
|
@ -17,15 +17,15 @@ git -C "$work/proj" add -A && git -C "$work/proj" commit -qm init && git -C "$wo
|
||||||
printf 'GIT_ACCESS_TOKEN=dummy\n' > "$work/.env.gitea"
|
printf 'GIT_ACCESS_TOKEN=dummy\n' > "$work/.env.gitea"
|
||||||
run() { CLUSEV_DIR="$work/proj" CLUSEV_RELEASE_BRANCH=main CLUSEV_GITEA_ENV="$work/.env.gitea" bash "$here/clusev-release.sh" "$@"; }
|
run() { CLUSEV_DIR="$work/proj" CLUSEV_RELEASE_BRANCH=main CLUSEV_GITEA_ENV="$work/.env.gitea" bash "$here/clusev-release.sh" "$@"; }
|
||||||
|
|
||||||
# happy path: minor -> 0.10.0-beta1, config bumped, tag pushed to the remote
|
# happy path: minor -> 0.10.0-rc1, config bumped, tag pushed to the remote
|
||||||
tag="$(run stage 0.10.0)"
|
tag="$(run stage 0.10.0)"
|
||||||
test "$tag" = "v0.10.0-beta1" || { echo "FAIL: expected v0.10.0-beta1, got '$tag'"; exit 1; }
|
test "$tag" = "v0.10.0-rc1" || { echo "FAIL: expected v0.10.0-rc1, got '$tag'"; exit 1; }
|
||||||
grep -q "'version' => '0.10.0-beta1'" "$work/proj/config/clusev.php" || { echo "FAIL: config not bumped"; exit 1; }
|
grep -q "'version' => '0.10.0-rc1'" "$work/proj/config/clusev.php" || { echo "FAIL: config not bumped"; exit 1; }
|
||||||
git -C "$work/remote.git" tag | grep -qx 'v0.10.0-beta1' || { echo "FAIL: tag not pushed to remote"; exit 1; }
|
git -C "$work/remote.git" tag | grep -qx 'v0.10.0-rc1' || { echo "FAIL: tag not pushed to remote"; exit 1; }
|
||||||
|
|
||||||
# second run, same target -> beta2
|
# second run, same target -> rc2
|
||||||
tag2="$(run stage 0.10.0)"
|
tag2="$(run stage 0.10.0)"
|
||||||
test "$tag2" = "v0.10.0-beta2" || { echo "FAIL: expected beta2, got '$tag2'"; exit 1; }
|
test "$tag2" = "v0.10.0-rc2" || { echo "FAIL: expected rc2, got '$tag2'"; exit 1; }
|
||||||
|
|
||||||
# downgrade rejected (target below the current base 0.10.0)
|
# downgrade rejected (target below the current base 0.10.0)
|
||||||
if run stage 0.9.0 2>/dev/null; then echo "FAIL: downgrade not rejected"; exit 1; fi
|
if run stage 0.9.0 2>/dev/null; then echo "FAIL: downgrade not rejected"; exit 1; fi
|
||||||
|
|
@ -49,7 +49,7 @@ chmod +x "$work/remote.git/hooks/pre-receive"
|
||||||
before="$(git -C "$work/proj" rev-parse HEAD)"
|
before="$(git -C "$work/proj" rev-parse HEAD)"
|
||||||
if run stage 0.11.0 2>/dev/null; then echo "FAIL: push to a rejecting remote should fail"; exit 1; fi
|
if run stage 0.11.0 2>/dev/null; then echo "FAIL: push to a rejecting remote should fail"; exit 1; fi
|
||||||
rm -f "$work/remote.git/hooks/pre-receive"
|
rm -f "$work/remote.git/hooks/pre-receive"
|
||||||
git -C "$work/proj" tag | grep -qx 'v0.11.0-beta1' && { echo "FAIL: tag not rolled back after push reject"; exit 1; }
|
git -C "$work/proj" tag | grep -qx 'v0.11.0-rc1' && { echo "FAIL: tag not rolled back after push reject"; exit 1; }
|
||||||
test "$(git -C "$work/proj" rev-parse HEAD)" = "$before" || { echo "FAIL: HEAD not rolled back after push reject"; exit 1; }
|
test "$(git -C "$work/proj" rev-parse HEAD)" = "$before" || { echo "FAIL: HEAD not rolled back after push reject"; exit 1; }
|
||||||
test -z "$(git -C "$work/proj" status --porcelain)" || { echo "FAIL: config not restored (dirty) after rollback"; exit 1; }
|
test -z "$(git -C "$work/proj" status --porcelain)" || { echo "FAIL: config not restored (dirty) after rollback"; exit 1; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ return [
|
||||||
'nav' => 'Release',
|
'nav' => 'Release',
|
||||||
'eyebrow' => 'Release-Pipeline',
|
'eyebrow' => 'Release-Pipeline',
|
||||||
'heading' => 'Release-Steuerung',
|
'heading' => 'Release-Steuerung',
|
||||||
'subtitle' => 'Beta auf Staging schneiden — nur auf der Dev-Instanz.',
|
'subtitle' => 'Release Candidate auf Staging schneiden — nur auf der Dev-Instanz.',
|
||||||
'dev_instance' => 'Dev-Instanz',
|
'dev_instance' => 'Dev-Instanz',
|
||||||
|
|
||||||
'current' => 'Aktuelle Version',
|
'current' => 'Aktuelle Version',
|
||||||
|
|
@ -13,28 +13,28 @@ return [
|
||||||
'source' => 'Quelle',
|
'source' => 'Quelle',
|
||||||
|
|
||||||
// option tiles
|
// option tiles
|
||||||
'choose' => 'Beta schneiden',
|
'choose' => 'Release Candidate schneiden',
|
||||||
'kind_continue' => 'Nächste Beta',
|
'kind_continue' => 'Nächster RC',
|
||||||
'kind_patch' => 'Patch',
|
'kind_patch' => 'Patch',
|
||||||
'kind_minor' => 'Minor',
|
'kind_minor' => 'Minor',
|
||||||
'kind_major' => 'Major',
|
'kind_major' => 'Major',
|
||||||
'tile_beta' => 'Beta-Tag',
|
'tile_rc' => 'RC-Tag',
|
||||||
'hint' => 'Schneidet einen Beta-Tag und pusht ihn nach Gitea → Mirror → CI → Staging.',
|
'hint' => 'Schneidet einen RC-Tag und pusht ihn nach Gitea → Mirror → CI → Staging.',
|
||||||
|
|
||||||
// running / result
|
// running / result
|
||||||
'running_title' => 'Release läuft',
|
'running_title' => 'Release läuft',
|
||||||
'running' => 'Beta :target wird geschnitten und gepusht …',
|
'running' => 'Release Candidate :target wird geschnitten und gepusht …',
|
||||||
'in_progress' => 'läuft',
|
'in_progress' => 'läuft',
|
||||||
'staged' => 'Beta :tag gepusht → Mirror → CI → Staging.',
|
'staged' => 'Release Candidate :tag gepusht → Mirror → CI → Staging.',
|
||||||
'staged_label' => 'Zuletzt gepusht:',
|
'staged_label' => 'Zuletzt gepusht:',
|
||||||
'failed' => 'Release fehlgeschlagen: :reason',
|
'failed' => 'Release fehlgeschlagen: :reason',
|
||||||
|
|
||||||
// pipeline rail (Tag → Mirror → CI; build readiness of a beta)
|
// pipeline rail (Tag → Mirror → CI; build readiness of a release candidate)
|
||||||
'pipeline' => 'Pipeline',
|
'pipeline' => 'Pipeline',
|
||||||
'pipe_sub_gitea' => 'Quelle · Tag wird hier erzeugt',
|
'pipe_sub_gitea' => 'Quelle · Tag wird hier erzeugt',
|
||||||
'pipe_sub_mirror' => 'Automatischer Push-Mirror',
|
'pipe_sub_mirror' => 'Automatischer Push-Mirror',
|
||||||
'pipe_sub_ci' => 'Tests laufen',
|
'pipe_sub_ci' => 'Tests laufen',
|
||||||
'no_beta_running' => 'Keine Beta in Arbeit.',
|
'no_rc_running' => 'Kein Release Candidate in Arbeit.',
|
||||||
'state_done' => 'fertig',
|
'state_done' => 'fertig',
|
||||||
'state_running' => 'läuft …',
|
'state_running' => 'läuft …',
|
||||||
'state_failed' => 'fehlgeschlagen',
|
'state_failed' => 'fehlgeschlagen',
|
||||||
|
|
@ -42,9 +42,9 @@ return [
|
||||||
'state_unknown' => 'unbekannt',
|
'state_unknown' => 'unbekannt',
|
||||||
|
|
||||||
// confirm modal (R5)
|
// confirm modal (R5)
|
||||||
'confirm_title' => 'Beta auf Staging schneiden?',
|
'confirm_title' => 'Release Candidate auf Staging schneiden?',
|
||||||
'confirm_body' => 'Schneidet eine Beta von :target, taggt sie und pusht nach Gitea → Mirror → CI → Staging. Der Tag ist unwiderruflich.',
|
'confirm_body' => 'Schneidet einen Release Candidate von :target, taggt ihn und pusht nach Gitea → Mirror → CI → Staging. Der Tag ist unwiderruflich.',
|
||||||
'confirm_action' => 'Beta schneiden',
|
'confirm_action' => 'Release Candidate schneiden',
|
||||||
|
|
||||||
// errors
|
// errors
|
||||||
'invalid_target' => 'Ungültige Ziel-Version.',
|
'invalid_target' => 'Ungültige Ziel-Version.',
|
||||||
|
|
@ -57,12 +57,12 @@ return [
|
||||||
'deploy_public' => 'Deploy to Public (:tag)',
|
'deploy_public' => 'Deploy to Public (:tag)',
|
||||||
'promote_stable' => 'Promote to Stable (:v)',
|
'promote_stable' => 'Promote to Stable (:v)',
|
||||||
'publish_hint' => 'Löst einen GitHub-Workflow aus, der die Version ins öffentliche Repo befördert.',
|
'publish_hint' => 'Löst einen GitHub-Workflow aus, der die Version ins öffentliche Repo befördert.',
|
||||||
'public_confirm_title' => 'Beta öffentlich machen?',
|
'public_confirm_title' => 'Release Candidate öffentlich machen?',
|
||||||
'public_confirm_body' => 'Befördert :tag in den öffentlichen Beta-Kanal (clusev/clusev).',
|
'public_confirm_body' => 'Befördert :tag ins öffentliche Repo (clusev/clusev).',
|
||||||
'public_action' => 'Veröffentlichen',
|
'public_action' => 'Veröffentlichen',
|
||||||
'public_dispatched' => 'Public-Promotion ausgelöst: :tag.',
|
'public_dispatched' => 'Public-Promotion ausgelöst: :tag.',
|
||||||
'stable_confirm_title' => 'Als Stable freigeben?',
|
'stable_confirm_title' => 'Als Stable freigeben?',
|
||||||
'stable_confirm_body' => 'Finalisiert die Beta als stabile :v im öffentlichen Stable-Kanal.',
|
'stable_confirm_body' => 'Finalisiert den Release Candidate als stabile :v im öffentlichen Stable-Kanal.',
|
||||||
'stable_action' => 'Stable freigeben',
|
'stable_action' => 'Stable freigeben',
|
||||||
'stable_dispatched' => 'Stable-Promotion ausgelöst: :v.',
|
'stable_dispatched' => 'Stable-Promotion ausgelöst: :v.',
|
||||||
'promote_failed' => 'Auslösen fehlgeschlagen — Token-Scope (Actions: Write) prüfen.',
|
'promote_failed' => 'Auslösen fehlgeschlagen — Token-Scope (Actions: Write) prüfen.',
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ return [
|
||||||
'nav' => 'Release',
|
'nav' => 'Release',
|
||||||
'eyebrow' => 'Release pipeline',
|
'eyebrow' => 'Release pipeline',
|
||||||
'heading' => 'Release control',
|
'heading' => 'Release control',
|
||||||
'subtitle' => 'Cut a staging beta — dev instance only.',
|
'subtitle' => 'Cut a staging release candidate — dev instance only.',
|
||||||
'dev_instance' => 'Dev instance',
|
'dev_instance' => 'Dev instance',
|
||||||
|
|
||||||
'current' => 'Current version',
|
'current' => 'Current version',
|
||||||
|
|
@ -13,28 +13,28 @@ return [
|
||||||
'source' => 'Source',
|
'source' => 'Source',
|
||||||
|
|
||||||
// option tiles
|
// option tiles
|
||||||
'choose' => 'Cut a beta',
|
'choose' => 'Cut a release candidate',
|
||||||
'kind_continue' => 'Next beta',
|
'kind_continue' => 'Next RC',
|
||||||
'kind_patch' => 'Patch',
|
'kind_patch' => 'Patch',
|
||||||
'kind_minor' => 'Minor',
|
'kind_minor' => 'Minor',
|
||||||
'kind_major' => 'Major',
|
'kind_major' => 'Major',
|
||||||
'tile_beta' => 'Beta tag',
|
'tile_rc' => 'RC tag',
|
||||||
'hint' => 'Cuts a beta tag and pushes it to Gitea → mirror → CI → staging.',
|
'hint' => 'Cuts an RC tag and pushes it to Gitea → mirror → CI → staging.',
|
||||||
|
|
||||||
// running / result
|
// running / result
|
||||||
'running_title' => 'Release running',
|
'running_title' => 'Release running',
|
||||||
'running' => 'Cutting and pushing beta :target …',
|
'running' => 'Cutting and pushing release candidate :target …',
|
||||||
'in_progress' => 'running',
|
'in_progress' => 'running',
|
||||||
'staged' => 'Beta :tag pushed → mirror → CI → staging.',
|
'staged' => 'Release candidate :tag pushed → mirror → CI → staging.',
|
||||||
'staged_label' => 'Last pushed:',
|
'staged_label' => 'Last pushed:',
|
||||||
'failed' => 'Release failed: :reason',
|
'failed' => 'Release failed: :reason',
|
||||||
|
|
||||||
// pipeline rail (Tag → Mirror → CI; build readiness of a beta)
|
// pipeline rail (Tag → Mirror → CI; build readiness of a release candidate)
|
||||||
'pipeline' => 'Pipeline',
|
'pipeline' => 'Pipeline',
|
||||||
'pipe_sub_gitea' => 'Source · tag is cut here',
|
'pipe_sub_gitea' => 'Source · tag is cut here',
|
||||||
'pipe_sub_mirror' => 'Automatic push mirror',
|
'pipe_sub_mirror' => 'Automatic push mirror',
|
||||||
'pipe_sub_ci' => 'Tests run',
|
'pipe_sub_ci' => 'Tests run',
|
||||||
'no_beta_running' => 'No beta in flight.',
|
'no_rc_running' => 'No release candidate in flight.',
|
||||||
'state_done' => 'done',
|
'state_done' => 'done',
|
||||||
'state_running' => 'running …',
|
'state_running' => 'running …',
|
||||||
'state_failed' => 'failed',
|
'state_failed' => 'failed',
|
||||||
|
|
@ -42,9 +42,9 @@ return [
|
||||||
'state_unknown' => 'unknown',
|
'state_unknown' => 'unknown',
|
||||||
|
|
||||||
// confirm modal (R5)
|
// confirm modal (R5)
|
||||||
'confirm_title' => 'Cut a staging beta?',
|
'confirm_title' => 'Cut a staging release candidate?',
|
||||||
'confirm_body' => 'Cuts a beta of :target, tags it and pushes to Gitea → mirror → CI → staging. The tag is irreversible.',
|
'confirm_body' => 'Cuts a release candidate of :target, tags it and pushes to Gitea → mirror → CI → staging. The tag is irreversible.',
|
||||||
'confirm_action' => 'Cut beta',
|
'confirm_action' => 'Cut release candidate',
|
||||||
|
|
||||||
// errors
|
// errors
|
||||||
'invalid_target' => 'Invalid target version.',
|
'invalid_target' => 'Invalid target version.',
|
||||||
|
|
@ -57,12 +57,12 @@ return [
|
||||||
'deploy_public' => 'Deploy to Public (:tag)',
|
'deploy_public' => 'Deploy to Public (:tag)',
|
||||||
'promote_stable' => 'Promote to Stable (:v)',
|
'promote_stable' => 'Promote to Stable (:v)',
|
||||||
'publish_hint' => 'Triggers a GitHub workflow that promotes the version to the public repo.',
|
'publish_hint' => 'Triggers a GitHub workflow that promotes the version to the public repo.',
|
||||||
'public_confirm_title' => 'Make the beta public?',
|
'public_confirm_title' => 'Make the release candidate public?',
|
||||||
'public_confirm_body' => 'Promotes :tag to the public beta channel (clusev/clusev).',
|
'public_confirm_body' => 'Promotes :tag to the public repo (clusev/clusev).',
|
||||||
'public_action' => 'Publish',
|
'public_action' => 'Publish',
|
||||||
'public_dispatched' => 'Public promotion triggered: :tag.',
|
'public_dispatched' => 'Public promotion triggered: :tag.',
|
||||||
'stable_confirm_title' => 'Release as stable?',
|
'stable_confirm_title' => 'Release as stable?',
|
||||||
'stable_confirm_body' => 'Finalises the beta as stable :v in the public stable channel.',
|
'stable_confirm_body' => 'Finalises the release candidate as stable :v in the public stable channel.',
|
||||||
'stable_action' => 'Release stable',
|
'stable_action' => 'Release stable',
|
||||||
'stable_dispatched' => 'Stable promotion triggered: :v.',
|
'stable_dispatched' => 'Stable promotion triggered: :v.',
|
||||||
'promote_failed' => 'Dispatch failed — check the token scope (Actions: Write).',
|
'promote_failed' => 'Dispatch failed — check the token scope (Actions: Write).',
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
@php
|
@php
|
||||||
$isBeta = str_contains($current, '-beta');
|
$isRc = str_contains($current, '-rc');
|
||||||
|
|
||||||
$tiles = [];
|
$tiles = [];
|
||||||
if (isset($targets['continueBeta'])) {
|
if (isset($targets['continueRc'])) {
|
||||||
$tiles[] = ['target' => $targets['continueBeta'], 'label' => __('release.kind_continue'), 'icon' => 'rotate', 'primary' => true];
|
$tiles[] = ['target' => $targets['continueRc'], 'label' => __('release.kind_continue'), 'icon' => 'rotate', 'primary' => true];
|
||||||
}
|
}
|
||||||
$tiles[] = ['target' => $targets['patch'], 'label' => __('release.kind_patch'), 'icon' => 'tag', 'primary' => false];
|
$tiles[] = ['target' => $targets['patch'], 'label' => __('release.kind_patch'), 'icon' => 'tag', 'primary' => false];
|
||||||
$tiles[] = ['target' => $targets['minor'], 'label' => __('release.kind_minor'), 'icon' => 'tag', 'primary' => false];
|
$tiles[] = ['target' => $targets['minor'], 'label' => __('release.kind_minor'), 'icon' => 'tag', 'primary' => false];
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
<dl class="flex gap-7 font-mono text-[11px]">
|
<dl class="flex gap-7 font-mono text-[11px]">
|
||||||
<div>
|
<div>
|
||||||
<dt class="uppercase tracking-wider text-ink-4">{{ __('release.channel') }}</dt>
|
<dt class="uppercase tracking-wider text-ink-4">{{ __('release.channel') }}</dt>
|
||||||
<dd class="mt-1 {{ $isBeta ? 'text-warning' : 'text-online' }}">{{ $isBeta ? 'beta' : 'stable' }}</dd>
|
<dd class="mt-1 {{ $isRc ? 'text-warning' : 'text-online' }}">{{ $isRc ? 'rc' : 'stable' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="uppercase tracking-wider text-ink-4">{{ __('release.source') }}</dt>
|
<dt class="uppercase tracking-wider text-ink-4">{{ __('release.source') }}</dt>
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
'text-accent-text' => $t['primary'],
|
'text-accent-text' => $t['primary'],
|
||||||
'text-ink' => ! $t['primary'],
|
'text-ink' => ! $t['primary'],
|
||||||
])>{{ $t['target'] }}</span>
|
])>{{ $t['target'] }}</span>
|
||||||
<x-badge tone="{{ $t['primary'] ? 'accent' : 'neutral' }}" class="ml-auto">{{ __('release.tile_beta') }}</x-badge>
|
<x-badge tone="{{ $t['primary'] ? 'accent' : 'neutral' }}" class="ml-auto">{{ __('release.tile_rc') }}</x-badge>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
@ -115,8 +115,8 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($isBeta)
|
@if ($isRc)
|
||||||
{{-- Publish the current beta --}}
|
{{-- Publish the current release candidate --}}
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2.5 font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('release.publish') }}</p>
|
<p class="mb-2.5 font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('release.publish') }}</p>
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3">
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
{{-- Right: live pipeline rail --}}
|
{{-- Right: live pipeline rail --}}
|
||||||
<x-panel :title="__('release.pipeline')">
|
<x-panel :title="__('release.pipeline')">
|
||||||
@if ($pipeline === null)
|
@if ($pipeline === null)
|
||||||
<p class="font-mono text-[11px] text-ink-4">{{ __('release.no_beta_running') }}</p>
|
<p class="font-mono text-[11px] text-ink-4">{{ __('release.no_rc_running') }}</p>
|
||||||
@else
|
@else
|
||||||
<ol>
|
<ol>
|
||||||
@foreach ($pipeline['steps'] as $s)
|
@foreach ($pipeline['steps'] as $s)
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ while IFS= read -r pat; do
|
||||||
done <<< "$forbidden"
|
done <<< "$forbidden"
|
||||||
|
|
||||||
git -C "$pub" add -A
|
git -C "$pub" add -A
|
||||||
# --allow-empty: stable promotion re-tags the same source commit as the last beta, so the
|
# --allow-empty: stable promotion re-tags the same source commit as the last release candidate, so the
|
||||||
# extracted tree is byte-identical and there is nothing to stage — still produce a release commit.
|
# extracted tree is byte-identical and there is nothing to stage — still produce a release commit.
|
||||||
git -C "$pub" commit -q --allow-empty -m "$msg"
|
git -C "$pub" commit -q --allow-empty -m "$msg"
|
||||||
git -C "$pub" tag "$tag"
|
git -C "$pub" tag "$tag"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ test -d "$work/pub/.git" || { echo "FAIL: public .git clobbered"; exit
|
||||||
git -C "$work/pub" rev-parse v1.0.0 >/dev/null 2>&1 || { echo "FAIL: tag missing"; 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; }
|
git -C "$work/pub" log -1 --pretty=%s | grep -qx 'Release v1.0.0' || { echo "FAIL: commit message"; exit 1; }
|
||||||
|
|
||||||
# Beta -> stable: stable re-tags the SAME source commit, so the extracted tree is byte-identical
|
# RC -> stable: stable re-tags the SAME source commit, so the extracted tree is byte-identical
|
||||||
# to the last promotion. Promote a second tag (same tree) into the SAME pub repo and assert it
|
# to the last promotion. Promote a second tag (same tree) into the SAME pub repo and assert it
|
||||||
# still creates the tag — guards against `git commit` aborting on "nothing to commit".
|
# still creates the tag — guards against `git commit` aborting on "nothing to commit".
|
||||||
git -C "$work/src" tag v1.0.1 v1.0.0
|
git -C "$work/src" tag v1.0.1 v1.0.0
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class PipelineStatusTest extends TestCase
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
Cache::flush();
|
Cache::flush();
|
||||||
config()->set('clusev.version', '0.9.61-beta1');
|
config()->set('clusev.version', '0.9.61-rc1');
|
||||||
config()->set('clusev.github_token', 'tok_secret');
|
config()->set('clusev.github_token', 'tok_secret');
|
||||||
config()->set('clusev.staging_slug', self::SLUG);
|
config()->set('clusev.staging_slug', self::SLUG);
|
||||||
}
|
}
|
||||||
|
|
@ -46,27 +46,27 @@ class PipelineStatusTest extends TestCase
|
||||||
Http::swap(new HttpFactory(app('events')));
|
Http::swap(new HttpFactory(app('events')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_non_beta_version_has_no_pipeline(): void
|
public function test_non_rc_version_has_no_pipeline(): void
|
||||||
{
|
{
|
||||||
config()->set('clusev.version', '0.9.60');
|
config()->set('clusev.version', '0.9.60');
|
||||||
$this->assertNull(app(PipelineStatus::class)->forTrackedBeta());
|
$this->assertNull(app(PipelineStatus::class)->forTrackedRc());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_tag_step_is_done_for_the_running_beta(): void
|
public function test_tag_step_is_done_for_the_running_rc(): void
|
||||||
{
|
{
|
||||||
Http::fake(['api.github.com/*' => Http::response([], 404)]);
|
Http::fake(['api.github.com/*' => Http::response([], 404)]);
|
||||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
$p = app(PipelineStatus::class)->forTrackedRc();
|
||||||
$this->assertSame('v0.9.61-beta1', $p['tag']);
|
$this->assertSame('v0.9.61-rc1', $p['tag']);
|
||||||
$this->assertSame('done', $this->step($p, 'tag')['state']);
|
$this->assertSame('done', $this->step($p, 'tag')['state']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_mirror_done_when_the_tag_exists_on_github(): void
|
public function test_mirror_done_when_the_tag_exists_on_github(): void
|
||||||
{
|
{
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'api.github.com/repos/acme/staging/git/refs/tags/v0.9.61-beta1' => Http::response(['ref' => 'refs/tags/v0.9.61-beta1']),
|
'api.github.com/repos/acme/staging/git/refs/tags/v0.9.61-rc1' => Http::response(['ref' => 'refs/tags/v0.9.61-rc1']),
|
||||||
'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => []]),
|
'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => []]),
|
||||||
]);
|
]);
|
||||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
$p = app(PipelineStatus::class)->forTrackedRc();
|
||||||
$this->assertSame('done', $this->step($p, 'mirror')['state']);
|
$this->assertSame('done', $this->step($p, 'mirror')['state']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,14 +76,14 @@ class PipelineStatusTest extends TestCase
|
||||||
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response([], 404),
|
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response([], 404),
|
||||||
'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => []]),
|
'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => []]),
|
||||||
]);
|
]);
|
||||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
$p = app(PipelineStatus::class)->forTrackedRc();
|
||||||
$this->assertSame('pending', $this->step($p, 'mirror')['state']);
|
$this->assertSame('pending', $this->step($p, 'mirror')['state']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ci_running_then_done_then_failed(): void
|
public function test_ci_running_then_done_then_failed(): void
|
||||||
{
|
{
|
||||||
$run = fn (string $status, ?string $conclusion) => Http::response(['workflow_runs' => [[
|
$run = fn (string $status, ?string $conclusion) => Http::response(['workflow_runs' => [[
|
||||||
'head_branch' => 'v0.9.61-beta1', 'status' => $status, 'conclusion' => $conclusion,
|
'head_branch' => 'v0.9.61-rc1', 'status' => $status, 'conclusion' => $conclusion,
|
||||||
'html_url' => 'https://github.com/acme/staging/actions/runs/1',
|
'html_url' => 'https://github.com/acme/staging/actions/runs/1',
|
||||||
]]]);
|
]]]);
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ class PipelineStatusTest extends TestCase
|
||||||
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
||||||
'api.github.com/repos/acme/staging/actions/runs*' => $run('in_progress', null),
|
'api.github.com/repos/acme/staging/actions/runs*' => $run('in_progress', null),
|
||||||
]);
|
]);
|
||||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
$p = app(PipelineStatus::class)->forTrackedRc();
|
||||||
$this->assertSame('running', $this->step($p, 'ci')['state']);
|
$this->assertSame('running', $this->step($p, 'ci')['state']);
|
||||||
$this->assertTrue($p['ciRunning']);
|
$this->assertTrue($p['ciRunning']);
|
||||||
$this->assertStringContainsString('runs/1', $this->step($p, 'ci')['url']);
|
$this->assertStringContainsString('runs/1', $this->step($p, 'ci')['url']);
|
||||||
|
|
@ -101,14 +101,14 @@ class PipelineStatusTest extends TestCase
|
||||||
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
||||||
'api.github.com/repos/acme/staging/actions/runs*' => $run('completed', 'success'),
|
'api.github.com/repos/acme/staging/actions/runs*' => $run('completed', 'success'),
|
||||||
]);
|
]);
|
||||||
$this->assertSame('done', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
|
$this->assertSame('done', $this->step(app(PipelineStatus::class)->forTrackedRc(), 'ci')['state']);
|
||||||
|
|
||||||
$this->resetForNextPhase();
|
$this->resetForNextPhase();
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
||||||
'api.github.com/repos/acme/staging/actions/runs*' => $run('completed', 'failure'),
|
'api.github.com/repos/acme/staging/actions/runs*' => $run('completed', 'failure'),
|
||||||
]);
|
]);
|
||||||
$this->assertSame('failed', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
|
$this->assertSame('failed', $this->step(app(PipelineStatus::class)->forTrackedRc(), 'ci')['state']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ci_pending_when_no_run_matches_the_tag(): void
|
public function test_ci_pending_when_no_run_matches_the_tag(): void
|
||||||
|
|
@ -117,14 +117,14 @@ class PipelineStatusTest extends TestCase
|
||||||
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
||||||
'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => [['head_branch' => 'v9.9.9', 'status' => 'completed', 'conclusion' => 'success']]]),
|
'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => [['head_branch' => 'v9.9.9', 'status' => 'completed', 'conclusion' => 'success']]]),
|
||||||
]);
|
]);
|
||||||
$this->assertSame('pending', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
|
$this->assertSame('pending', $this->step(app(PipelineStatus::class)->forTrackedRc(), 'ci')['state']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_no_token_makes_mirror_and_ci_unknown_without_calling_github(): void
|
public function test_no_token_makes_mirror_and_ci_unknown_without_calling_github(): void
|
||||||
{
|
{
|
||||||
config()->set('clusev.github_token', '');
|
config()->set('clusev.github_token', '');
|
||||||
Http::fake();
|
Http::fake();
|
||||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
$p = app(PipelineStatus::class)->forTrackedRc();
|
||||||
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
|
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
|
||||||
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
|
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
|
||||||
Http::assertNothingSent();
|
Http::assertNothingSent();
|
||||||
|
|
@ -133,7 +133,7 @@ class PipelineStatusTest extends TestCase
|
||||||
public function test_token_is_sent_in_the_header_never_in_the_url(): void
|
public function test_token_is_sent_in_the_header_never_in_the_url(): void
|
||||||
{
|
{
|
||||||
Http::fake(['api.github.com/*' => Http::response(['ref' => 'x'])]);
|
Http::fake(['api.github.com/*' => Http::response(['ref' => 'x'])]);
|
||||||
app(PipelineStatus::class)->forTrackedBeta();
|
app(PipelineStatus::class)->forTrackedRc();
|
||||||
Http::assertSent(fn ($req) => $req->hasHeader('Authorization')
|
Http::assertSent(fn ($req) => $req->hasHeader('Authorization')
|
||||||
&& ! str_contains($req->url(), 'tok_secret'));
|
&& ! str_contains($req->url(), 'tok_secret'));
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +141,7 @@ class PipelineStatusTest extends TestCase
|
||||||
public function test_github_5xx_degrades_to_unknown(): void
|
public function test_github_5xx_degrades_to_unknown(): void
|
||||||
{
|
{
|
||||||
Http::fake(['api.github.com/*' => Http::response('boom', 500)]);
|
Http::fake(['api.github.com/*' => Http::response('boom', 500)]);
|
||||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
$p = app(PipelineStatus::class)->forTrackedRc();
|
||||||
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
|
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
|
||||||
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
|
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +149,7 @@ class PipelineStatusTest extends TestCase
|
||||||
public function test_github_throwing_degrades_to_unknown_never_throws(): void
|
public function test_github_throwing_degrades_to_unknown_never_throws(): void
|
||||||
{
|
{
|
||||||
Http::fake(['api.github.com/*' => fn () => throw new \RuntimeException('net down')]);
|
Http::fake(['api.github.com/*' => fn () => throw new \RuntimeException('net down')]);
|
||||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
$p = app(PipelineStatus::class)->forTrackedRc();
|
||||||
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
|
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
|
||||||
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
|
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,12 @@ class PromotionServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
||||||
|
|
||||||
$this->assertTrue(app(PromotionService::class)->deployPublic('v0.10.0-beta1'));
|
$this->assertTrue(app(PromotionService::class)->deployPublic('v0.10.0-rc1'));
|
||||||
|
|
||||||
Http::assertSent(fn ($req) => $req->method() === 'POST'
|
Http::assertSent(fn ($req) => $req->method() === 'POST'
|
||||||
&& str_contains($req->url(), 'repos/acme/staging/actions/workflows/promote-public.yml/dispatches')
|
&& str_contains($req->url(), 'repos/acme/staging/actions/workflows/promote-public.yml/dispatches')
|
||||||
&& $req['ref'] === 'feat/v1-foundation'
|
&& $req['ref'] === 'feat/v1-foundation'
|
||||||
&& $req['inputs']['tag'] === 'v0.10.0-beta1'
|
&& $req['inputs']['tag'] === 'v0.10.0-rc1'
|
||||||
&& $req->hasHeader('Authorization')
|
&& $req->hasHeader('Authorization')
|
||||||
&& ! str_contains($req->url(), 'tok_secret'));
|
&& ! str_contains($req->url(), 'tok_secret'));
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ class PromotionServiceTest extends TestCase
|
||||||
public function test_dispatch_returns_false_on_a_github_error(): void
|
public function test_dispatch_returns_false_on_a_github_error(): void
|
||||||
{
|
{
|
||||||
Http::fake(['api.github.com/*' => Http::response('forbidden', 403)]);
|
Http::fake(['api.github.com/*' => Http::response('forbidden', 403)]);
|
||||||
$this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-beta1'));
|
$this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-rc1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_dispatch_without_a_token_does_not_call_github(): void
|
public function test_dispatch_without_a_token_does_not_call_github(): void
|
||||||
|
|
@ -59,7 +59,7 @@ class PromotionServiceTest extends TestCase
|
||||||
config()->set('clusev.github_token', '');
|
config()->set('clusev.github_token', '');
|
||||||
Http::fake();
|
Http::fake();
|
||||||
|
|
||||||
$this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-beta1'));
|
$this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-rc1'));
|
||||||
Http::assertNothingSent();
|
Http::assertNothingSent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,13 @@ class ReleaseBridgeTest extends TestCase
|
||||||
$id = app(ReleaseBridge::class)->requestStaging('0.10.0');
|
$id = app(ReleaseBridge::class)->requestStaging('0.10.0');
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$this->dir."/release-result-{$id}.json",
|
$this->dir."/release-result-{$id}.json",
|
||||||
json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-beta1', 'message' => 'ok'])
|
json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-rc1', 'message' => 'ok'])
|
||||||
);
|
);
|
||||||
|
|
||||||
$res = app(ReleaseBridge::class)->result($id);
|
$res = app(ReleaseBridge::class)->result($id);
|
||||||
|
|
||||||
$this->assertTrue($res['ok']);
|
$this->assertTrue($res['ok']);
|
||||||
$this->assertSame('v0.10.0-beta1', $res['tag']);
|
$this->assertSame('v0.10.0-rc1', $res['tag']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_result_is_null_until_the_host_replies(): void
|
public function test_result_is_null_until_the_host_replies(): void
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,16 @@ class ReleaseCheckerChannelTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_channel_is_always_stable_even_if_a_beta_setting_lingers(): void
|
public function test_channel_is_always_stable_even_if_a_prerelease_setting_lingers(): void
|
||||||
{
|
{
|
||||||
Setting::put('release_channel', 'beta'); // a stale setting from before the drop
|
Setting::put('release_channel', 'rc'); // a stale setting from before the drop
|
||||||
$this->assertSame('stable', app(ReleaseChecker::class)->channel());
|
$this->assertSame('stable', app(ReleaseChecker::class)->channel());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_newest_version_ignores_rc_and_beta_prereleases(): void
|
public function test_newest_version_ignores_rc_prereleases(): void
|
||||||
{
|
{
|
||||||
$checker = app(ReleaseChecker::class);
|
$checker = app(ReleaseChecker::class);
|
||||||
$tags = ['v0.11.0', 'v0.12.0-rc1', 'v0.12.0-beta2', 'v0.10.0'];
|
$tags = ['v0.11.0', 'v0.12.0-rc1', 'v0.12.0-rc2', 'v0.10.0'];
|
||||||
$this->assertSame('0.11.0', $checker->newestVersion($tags, 'stable'));
|
$this->assertSame('0.11.0', $checker->newestVersion($tags, 'stable'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,10 @@ class ReleasePageTest extends TestCase
|
||||||
$id = $c->get('pendingId');
|
$id = $c->get('pendingId');
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$this->dir."/release-result-{$id}.json",
|
$this->dir."/release-result-{$id}.json",
|
||||||
json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-beta1', 'message' => 'ok'])
|
json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-rc1', 'message' => 'ok'])
|
||||||
);
|
);
|
||||||
|
|
||||||
$c->call('pollResult')->assertSet('pendingId', null)->assertSee('v0.10.0-beta1');
|
$c->call('pollResult')->assertSet('pendingId', null)->assertSee('v0.10.0-rc1');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_poll_result_audits_a_host_failure_as_an_error(): void
|
public function test_poll_result_audits_a_host_failure_as_an_error(): void
|
||||||
|
|
@ -101,14 +101,14 @@ class ReleasePageTest extends TestCase
|
||||||
Livewire::test(Index::class)->assertStatus(404);
|
Livewire::test(Index::class)->assertStatus(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_render_includes_the_live_pipeline_for_a_beta(): void
|
public function test_render_includes_the_live_pipeline_for_an_rc(): void
|
||||||
{
|
{
|
||||||
config()->set('clusev.version', '0.9.61-beta1');
|
config()->set('clusev.version', '0.9.61-rc1');
|
||||||
config()->set('clusev.staging_slug', ''); // degrade: no GitHub calls
|
config()->set('clusev.staging_slug', ''); // degrade: no GitHub calls
|
||||||
config()->set('clusev.github_token', '');
|
config()->set('clusev.github_token', '');
|
||||||
|
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
->assertViewHas('pipeline', fn ($p) => is_array($p) && $p['tag'] === 'v0.9.61-beta1');
|
->assertViewHas('pipeline', fn ($p) => is_array($p) && $p['tag'] === 'v0.9.61-rc1');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_render_pipeline_is_null_for_a_stable_version(): void
|
public function test_render_pipeline_is_null_for_a_stable_version(): void
|
||||||
|
|
@ -117,15 +117,15 @@ class ReleasePageTest extends TestCase
|
||||||
Livewire::test(Index::class)->assertViewHas('pipeline', null);
|
Livewire::test(Index::class)->assertViewHas('pipeline', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_deploy_public_dispatches_and_audits_for_a_beta(): void
|
public function test_deploy_public_dispatches_and_audits_for_an_rc(): void
|
||||||
{
|
{
|
||||||
config()->set('clusev.version', '0.10.0-beta1');
|
config()->set('clusev.version', '0.10.0-rc1');
|
||||||
config()->set('clusev.github_token', 'tok');
|
config()->set('clusev.github_token', 'tok');
|
||||||
config()->set('clusev.staging_slug', 'acme/staging');
|
config()->set('clusev.staging_slug', 'acme/staging');
|
||||||
config()->set('clusev.release_branch', 'feat/v1-foundation');
|
config()->set('clusev.release_branch', 'feat/v1-foundation');
|
||||||
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
||||||
|
|
||||||
$token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']);
|
$token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-rc1']);
|
||||||
ConfirmToken::confirm($token); // the modal confirmation step
|
ConfirmToken::confirm($token); // the modal confirmation step
|
||||||
Livewire::test(Index::class)->call('applyDeployPublic', $token);
|
Livewire::test(Index::class)->call('applyDeployPublic', $token);
|
||||||
|
|
||||||
|
|
@ -158,11 +158,11 @@ class ReleasePageTest extends TestCase
|
||||||
|
|
||||||
public function test_publish_actions_are_a_no_op_for_a_stable_version(): void
|
public function test_publish_actions_are_a_no_op_for_a_stable_version(): void
|
||||||
{
|
{
|
||||||
config()->set('clusev.version', '0.10.0'); // not a beta
|
config()->set('clusev.version', '0.10.0'); // not an rc
|
||||||
config()->set('clusev.public_slug', ''); // degrade: no public-tags read on render
|
config()->set('clusev.public_slug', ''); // degrade: no public-tags read on render
|
||||||
Http::fake();
|
Http::fake();
|
||||||
|
|
||||||
$token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']);
|
$token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-rc1']);
|
||||||
ConfirmToken::confirm($token); // the modal confirmation step
|
ConfirmToken::confirm($token); // the modal confirmation step
|
||||||
Livewire::test(Index::class)->call('applyDeployPublic', $token);
|
Livewire::test(Index::class)->call('applyDeployPublic', $token);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,12 +236,12 @@ class TerminalTest extends TestCase
|
||||||
public function test_search_filters_the_server_rail(): void
|
public function test_search_filters_the_server_rail(): void
|
||||||
{
|
{
|
||||||
Server::create(['name' => 'alpha-web', 'ip' => '10.0.0.1', 'status' => 'online']);
|
Server::create(['name' => 'alpha-web', 'ip' => '10.0.0.1', 'status' => 'online']);
|
||||||
Server::create(['name' => 'beta-db', 'ip' => '10.0.0.2', 'status' => 'online']);
|
Server::create(['name' => 'delta-db', 'ip' => '10.0.0.2', 'status' => 'online']);
|
||||||
|
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
->set('search', 'alpha')
|
->set('search', 'alpha')
|
||||||
->assertSee('alpha-web')
|
->assertSee('alpha-web')
|
||||||
->assertDontSee('beta-db');
|
->assertDontSee('delta-db');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_search_box_appears_once_there_is_more_than_one_server(): void
|
public function test_search_box_appears_once_there_is_more_than_one_server(): void
|
||||||
|
|
|
||||||
|
|
@ -114,16 +114,16 @@ class VersionUpdateCheckTest extends TestCase
|
||||||
{
|
{
|
||||||
config()->set('clusev.version', '0.9.6');
|
config()->set('clusev.version', '0.9.6');
|
||||||
Http::fake([self::TAGS_URL => Http::response([
|
Http::fake([self::TAGS_URL => Http::response([
|
||||||
['name' => 'v1.0.0-beta1'], ['name' => 'v0.9.6'],
|
['name' => 'v1.0.0-rc1'], ['name' => 'v0.9.6'],
|
||||||
])]);
|
])]);
|
||||||
|
|
||||||
// stable: 1.0.0-beta1 is not offered, 0.9.6 == installed → current
|
// stable: 1.0.0-rc1 is not offered, 0.9.6 == installed → current
|
||||||
Setting::put('release_channel', 'stable');
|
Setting::put('release_channel', 'stable');
|
||||||
Livewire::test(Index::class)->call('checkUpdates')->assertSet('updateState', 'current');
|
Livewire::test(Index::class)->call('checkUpdates')->assertSet('updateState', 'current');
|
||||||
|
|
||||||
// a stale beta setting from before the channel drop is ignored → still current
|
// a stale prerelease setting from before the channel drop is ignored → still current
|
||||||
Cache::flush();
|
Cache::flush();
|
||||||
Setting::put('release_channel', 'beta');
|
Setting::put('release_channel', 'rc');
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
->call('checkUpdates')
|
->call('checkUpdates')
|
||||||
->assertSet('updateState', 'current');
|
->assertSet('updateState', 'current');
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,21 @@ class ReleasePlannerTest extends TestCase
|
||||||
$this->planner = new ReleasePlanner;
|
$this->planner = new ReleasePlanner;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_stable_version_offers_patch_minor_major_without_continue_beta(): void
|
public function test_stable_version_offers_patch_minor_major_without_continue_rc(): void
|
||||||
{
|
{
|
||||||
$t = $this->planner->proposedTargets('0.9.58');
|
$t = $this->planner->proposedTargets('0.9.58');
|
||||||
|
|
||||||
$this->assertSame('0.9.59', $t['patch']);
|
$this->assertSame('0.9.59', $t['patch']);
|
||||||
$this->assertSame('0.10.0', $t['minor']);
|
$this->assertSame('0.10.0', $t['minor']);
|
||||||
$this->assertSame('1.0.0', $t['major']);
|
$this->assertSame('1.0.0', $t['major']);
|
||||||
$this->assertArrayNotHasKey('continueBeta', $t);
|
$this->assertArrayNotHasKey('continueRc', $t);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_beta_version_also_offers_continue_beta_of_its_base(): void
|
public function test_rc_version_also_offers_continue_rc_of_its_base(): void
|
||||||
{
|
{
|
||||||
$t = $this->planner->proposedTargets('0.10.0-beta3');
|
$t = $this->planner->proposedTargets('0.10.0-rc3');
|
||||||
|
|
||||||
$this->assertSame('0.10.0', $t['continueBeta']);
|
$this->assertSame('0.10.0', $t['continueRc']);
|
||||||
$this->assertSame('0.10.1', $t['patch']);
|
$this->assertSame('0.10.1', $t['patch']);
|
||||||
$this->assertSame('0.11.0', $t['minor']);
|
$this->assertSame('0.11.0', $t['minor']);
|
||||||
$this->assertSame('1.0.0', $t['major']);
|
$this->assertSame('1.0.0', $t['major']);
|
||||||
|
|
@ -42,9 +42,9 @@ class ReleasePlannerTest extends TestCase
|
||||||
|
|
||||||
public function test_allowed_targets_are_the_proposed_values(): void
|
public function test_allowed_targets_are_the_proposed_values(): void
|
||||||
{
|
{
|
||||||
$allowed = $this->planner->allowedTargets('0.10.0-beta1');
|
$allowed = $this->planner->allowedTargets('0.10.0-rc1');
|
||||||
|
|
||||||
$this->assertContains('0.10.0', $allowed); // continueBeta
|
$this->assertContains('0.10.0', $allowed); // continueRc
|
||||||
$this->assertContains('0.11.0', $allowed); // minor
|
$this->assertContains('0.11.0', $allowed); // minor
|
||||||
$this->assertNotContains('0.9.0', $allowed);
|
$this->assertNotContains('0.9.0', $allowed);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue