Compare commits
6 Commits
d3b2ad2f23
...
8a7d91bd00
| Author | SHA1 | Date |
|---|---|---|
|
|
8a7d91bd00 | |
|
|
1eb4c4fef7 | |
|
|
67a69f3189 | |
|
|
ea33797236 | |
|
|
84f84cbb03 | |
|
|
7c5b4cf222 |
|
|
@ -0,0 +1,34 @@
|
|||
name: Yank a public release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to retract from the public repo (e.g. v0.10.0 or v0.10.0-beta1)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
yank:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Validate the tag format
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-beta[0-9]+)?$' \
|
||||
|| { echo "refusing to yank an unrecognised tag: $TAG" >&2; exit 1; }
|
||||
- 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: Delete the tag on the public repo
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
git -C pub push origin --delete "refs/tags/${TAG}"
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -13,6 +13,16 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
|
|||
|
||||
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||
|
||||
## [0.9.68] - 2026-06-23
|
||||
|
||||
### Hinzugefügt
|
||||
- **Veröffentlichen + Zurückziehen auf der Release-Seite.** Bei laufender Beta: **Deploy to Public**
|
||||
(Beta in den öffentlichen Beta-Kanal) und **Promote to Stable** (als `vX.Y.Z` in den Stable-Kanal) —
|
||||
beide lösen per GitHub-API (`workflow_dispatch`) die Promote-Workflows aus. Plus **Yank**: ein Public-
|
||||
Release zurückziehen (löscht den Tag auf `clusev/clusev` über einen neuen `yank.yml`-Workflow), mit
|
||||
Liste der letzten Public-Tags. Alle mit R5-Bestätigung + Audit; der Dashboard-Token triggert nur, der
|
||||
eigentliche Public-Push/-Delete läuft im Workflow mit `PUBLIC_REPO_TOKEN`. (Token braucht `Actions: Write`.)
|
||||
|
||||
## [0.9.66] - 2026-06-23
|
||||
|
||||
### Geändert
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Release;
|
|||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\PipelineStatus;
|
||||
use App\Services\PromotionService;
|
||||
use App\Services\ReleaseBridge;
|
||||
use App\Services\ReleasePlanner;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
|
|
@ -155,6 +156,133 @@ class Index extends Component
|
|||
$pipeline->refresh();
|
||||
}
|
||||
|
||||
/** The current in-flight beta tag (vX.Y.Z-betaN), or null when the running version is stable. */
|
||||
private function currentBetaTag(): ?string
|
||||
{
|
||||
$v = (string) config('clusev.version');
|
||||
|
||||
return str_contains($v, '-beta') ? 'v'.ltrim($v, 'vV') : null;
|
||||
}
|
||||
|
||||
/** Per-user throttle shared by all promotion dispatches (auto-expiring, never a lockout). */
|
||||
private function throttlePromote(): bool
|
||||
{
|
||||
$key = 'promote:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
$this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return false;
|
||||
}
|
||||
RateLimiter::hit($key, 600);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Record a promotion outcome (action on success, action.'_failed' on failure). */
|
||||
private function auditPromotion(string $action, string $target, bool $ok): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $ok ? $action : $action.'_failed',
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function confirmDeployPublic(): void
|
||||
{
|
||||
$tag = $this->currentBetaTag();
|
||||
if ($tag === null) {
|
||||
return;
|
||||
}
|
||||
$this->openConfirm('releasePublic', ['tag' => $tag],
|
||||
__('release.public_confirm_title'), __('release.public_confirm_body', ['tag' => $tag]),
|
||||
__('release.public_action'), danger: false, icon: 'tag');
|
||||
}
|
||||
|
||||
#[On('releasePublic')]
|
||||
public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void
|
||||
{
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'releasePublic');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
$tag = (string) ($payload['params']['tag'] ?? '');
|
||||
if ($tag === '' || $this->currentBetaTag() !== $tag || ! $this->throttlePromote()) {
|
||||
return;
|
||||
}
|
||||
$ok = $promotion->deployPublic($tag);
|
||||
$this->auditPromotion('deploy.public', $tag, $ok);
|
||||
$this->dispatch('notify',
|
||||
message: $ok ? __('release.public_dispatched', ['tag' => $tag]) : __('release.promote_failed'),
|
||||
level: $ok ? 'info' : 'error');
|
||||
}
|
||||
|
||||
public function confirmPromoteStable(): void
|
||||
{
|
||||
$tag = $this->currentBetaTag();
|
||||
if ($tag === null) {
|
||||
return;
|
||||
}
|
||||
$stable = 'v'.preg_replace('/-.*$/', '', ltrim((string) config('clusev.version'), 'vV'));
|
||||
$this->openConfirm('releaseStable', ['betaTag' => $tag],
|
||||
__('release.stable_confirm_title'), __('release.stable_confirm_body', ['v' => $stable]),
|
||||
__('release.stable_action'), danger: false, icon: 'tag');
|
||||
}
|
||||
|
||||
#[On('releaseStable')]
|
||||
public function applyPromoteStable(string $confirmToken, PromotionService $promotion): void
|
||||
{
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'releaseStable');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
$beta = (string) ($payload['params']['betaTag'] ?? '');
|
||||
if ($beta === '' || $this->currentBetaTag() !== $beta || ! $this->throttlePromote()) {
|
||||
return;
|
||||
}
|
||||
$stable = 'v'.preg_replace('/-.*$/', '', ltrim($beta, 'vV'));
|
||||
$ok = $promotion->promoteStable($beta, $stable);
|
||||
$this->auditPromotion('deploy.stable', $stable, $ok);
|
||||
$this->dispatch('notify',
|
||||
message: $ok ? __('release.stable_dispatched', ['v' => $stable]) : __('release.promote_failed'),
|
||||
level: $ok ? 'info' : 'error');
|
||||
}
|
||||
|
||||
public function confirmYank(string $tag, PromotionService $promotion): void
|
||||
{
|
||||
if (! in_array($tag, $promotion->publicTags(), true)) {
|
||||
$this->dispatch('notify', message: __('release.yank_unknown'), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->openConfirm('releaseYank', ['tag' => $tag],
|
||||
__('release.yank_confirm_title'), __('release.yank_confirm_body', ['tag' => $tag]),
|
||||
__('release.yank_action'), danger: true, icon: 'trash');
|
||||
}
|
||||
|
||||
#[On('releaseYank')]
|
||||
public function applyYank(string $confirmToken, PromotionService $promotion): void
|
||||
{
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'releaseYank');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
$tag = (string) ($payload['params']['tag'] ?? '');
|
||||
if ($tag === '' || ! in_array($tag, $promotion->publicTags(), true) || ! $this->throttlePromote()) {
|
||||
return;
|
||||
}
|
||||
$ok = $promotion->yank($tag);
|
||||
$this->auditPromotion('deploy.yank', $tag, $ok);
|
||||
$this->dispatch('notify',
|
||||
message: $ok ? __('release.yank_dispatched', ['tag' => $tag]) : __('release.promote_failed'),
|
||||
level: $ok ? 'info' : 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the shared ConfirmAction modal (R5) for the staging release. The issued token carries NO
|
||||
* audit descriptor — applyDeployStaging → deployStaging audits exactly once itself.
|
||||
|
|
@ -185,6 +313,8 @@ class Index extends Component
|
|||
'current' => $current,
|
||||
'targets' => app(ReleasePlanner::class)->proposedTargets($current),
|
||||
'pipeline' => app(PipelineStatus::class)->forTrackedBeta(),
|
||||
'publicTags' => app(PromotionService::class)->publicTags(),
|
||||
'stableTarget' => 'v'.preg_replace('/-.*$/', '', ltrim($current, 'vV')),
|
||||
])->title(__('release.title'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class AuditEvent extends Model
|
|||
private const ERROR_ACTIONS = [
|
||||
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
|
||||
'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed',
|
||||
'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Triggers the public-promotion / yank GitHub Actions workflows on the private staging repo via the
|
||||
* GitHub API (workflow_dispatch), and reads the public repo's tags. The trigger token
|
||||
* (config clusev.github_token, needs Actions:Write) ONLY dispatches — the workflows do the public
|
||||
* push/delete with their own PUBLIC_REPO_TOKEN. Reads of the public repo's tags are anonymous (it is
|
||||
* public). Every call degrades to false / [] (never throws); the token is only sent in the header.
|
||||
*/
|
||||
class PromotionService
|
||||
{
|
||||
public function deployPublic(string $betaTag): bool
|
||||
{
|
||||
return $this->dispatch('promote-public.yml', ['tag' => $betaTag]);
|
||||
}
|
||||
|
||||
public function promoteStable(string $betaTag, string $stableTag): bool
|
||||
{
|
||||
return $this->dispatch('promote-stable.yml', ['betaTag' => $betaTag, 'stableTag' => $stableTag]);
|
||||
}
|
||||
|
||||
public function yank(string $tag): bool
|
||||
{
|
||||
return $this->dispatch('yank.yml', ['tag' => $tag]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The public repo's tag names, newest first (anonymous read; the repo is public). [] on failure.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function publicTags(int $limit = 10): array
|
||||
{
|
||||
$slug = (string) config('clusev.public_slug');
|
||||
if ($slug === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Cached: render() (incl. the 5s page poll) calls this every render, and the anonymous GitHub
|
||||
// tag API is rate-limited (60/h per IP). 120s keeps it well under the limit; a yank reflects
|
||||
// once the workflow has actually deleted the tag (~minutes) anyway, so staleness is harmless.
|
||||
return Cache::remember("clusev:public-tags:{$slug}:{$limit}", 120, function () use ($slug, $limit) {
|
||||
try {
|
||||
$res = Http::timeout(5)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel'])
|
||||
->get("https://api.github.com/repos/{$slug}/tags", ['per_page' => $limit]);
|
||||
if (! $res->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '', (array) $res->json()),
|
||||
static fn (string $n): bool => $n !== '',
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,string> $inputs */
|
||||
private function dispatch(string $workflowFile, array $inputs): bool
|
||||
{
|
||||
$token = (string) config('clusev.github_token');
|
||||
$slug = (string) config('clusev.staging_slug');
|
||||
if ($token === '' || $slug === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$res = Http::timeout(5)->withToken($token)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel'])
|
||||
->post("https://api.github.com/repos/{$slug}/actions/workflows/{$workflowFile}/dispatches", [
|
||||
'ref' => (string) config('clusev.release_branch'),
|
||||
'inputs' => $inputs,
|
||||
]);
|
||||
|
||||
return $res->successful(); // 204 No Content on success
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,9 @@ class ConfirmToken
|
|||
'wgSetPort',
|
||||
'wgSetSubnet',
|
||||
'releaseStaged',
|
||||
'releasePublic',
|
||||
'releaseStable',
|
||||
'releaseYank',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
||||
'version' => '0.9.67-beta1',
|
||||
'version' => '0.9.68',
|
||||
|
||||
// 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
|
||||
|
|
@ -34,5 +34,10 @@ return [
|
|||
'github_token' => env('GIT_STAGING_ACCESS_TOKEN', ''),
|
||||
'staging_slug' => env('CLUSEV_STAGING_SLUG', ''),
|
||||
|
||||
// Public repo (for promotion targets + reading published tags). PUBLIC, so a tracked default is
|
||||
// fine. The dispatch ref is the branch the promote/yank workflows live on (mirrored to staging).
|
||||
'public_slug' => env('CLUSEV_PUBLIC_SLUG', 'clusev/clusev'),
|
||||
'release_branch' => env('CLUSEV_RELEASE_BRANCH', 'feat/v1-foundation'),
|
||||
|
||||
'license' => 'AGPL-3.0',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ return [
|
|||
'deploy.update_request' => 'Update angefordert',
|
||||
'deploy.staging_release' => 'Staging-Release angefordert',
|
||||
'deploy.staging_release_failed' => 'Staging-Release fehlgeschlagen',
|
||||
'deploy.public' => 'Public-Promotion ausgelöst',
|
||||
'deploy.public_failed' => 'Public-Promotion fehlgeschlagen',
|
||||
'deploy.stable' => 'Stable-Promotion ausgelöst',
|
||||
'deploy.stable_failed' => 'Stable-Promotion fehlgeschlagen',
|
||||
'deploy.yank' => 'Release zurückgezogen',
|
||||
'deploy.yank_failed' => 'Yank fehlgeschlagen',
|
||||
'fail2ban.ban' => 'fail2ban: IP gesperrt',
|
||||
'fail2ban.unban' => 'fail2ban: IP entsperrt',
|
||||
'fail2ban.configure' => 'fail2ban konfiguriert',
|
||||
|
|
|
|||
|
|
@ -51,4 +51,27 @@ return [
|
|||
'throttled' => 'Zu viele Versuche — in :seconds s erneut.',
|
||||
'write_failed' => 'Anfrage konnte nicht geschrieben werden (Signal-Ordner nicht beschreibbar).',
|
||||
'no_host_response' => 'Keine Antwort vom Host. Läuft der Release-Watcher?',
|
||||
|
||||
// publish + yank (B2)
|
||||
'publish' => 'Veröffentlichen',
|
||||
'deploy_public' => 'Deploy to Public (:tag)',
|
||||
'promote_stable' => 'Promote to Stable (:v)',
|
||||
'publish_hint' => 'Löst einen GitHub-Workflow aus, der die Version ins öffentliche Repo befördert.',
|
||||
'public_confirm_title' => 'Beta öffentlich machen?',
|
||||
'public_confirm_body' => 'Befördert :tag in den öffentlichen Beta-Kanal (clusev/clusev).',
|
||||
'public_action' => 'Veröffentlichen',
|
||||
'public_dispatched' => 'Public-Promotion ausgelöst: :tag.',
|
||||
'stable_confirm_title' => 'Als Stable freigeben?',
|
||||
'stable_confirm_body' => 'Finalisiert die Beta als stabile :v im öffentlichen Stable-Kanal.',
|
||||
'stable_action' => 'Stable freigeben',
|
||||
'stable_dispatched' => 'Stable-Promotion ausgelöst: :v.',
|
||||
'promote_failed' => 'Auslösen fehlgeschlagen — Token-Scope (Actions: Write) prüfen.',
|
||||
'yank_title' => 'Public-Release zurückziehen',
|
||||
'yank' => 'Yank',
|
||||
'yank_empty' => 'Keine Public-Tags ladbar.',
|
||||
'yank_unknown' => 'Dieser Tag ist nicht (mehr) public.',
|
||||
'yank_confirm_title' => 'Release zurückziehen?',
|
||||
'yank_confirm_body' => 'Löscht den Tag :tag auf clusev/clusev — Nutzer bekommen ihn nicht mehr angeboten. Die History bleibt.',
|
||||
'yank_action' => 'Zurückziehen',
|
||||
'yank_dispatched' => 'Yank ausgelöst: :tag.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ return [
|
|||
'deploy.update_request' => 'Update requested',
|
||||
'deploy.staging_release' => 'Staging release requested',
|
||||
'deploy.staging_release_failed' => 'Staging release failed',
|
||||
'deploy.public' => 'Public promotion triggered',
|
||||
'deploy.public_failed' => 'Public promotion failed',
|
||||
'deploy.stable' => 'Stable promotion triggered',
|
||||
'deploy.stable_failed' => 'Stable promotion failed',
|
||||
'deploy.yank' => 'Release retracted',
|
||||
'deploy.yank_failed' => 'Yank failed',
|
||||
'fail2ban.ban' => 'fail2ban: IP banned',
|
||||
'fail2ban.unban' => 'fail2ban: IP unbanned',
|
||||
'fail2ban.configure' => 'fail2ban configured',
|
||||
|
|
|
|||
|
|
@ -51,4 +51,27 @@ return [
|
|||
'throttled' => 'Too many attempts — retry in :seconds s.',
|
||||
'write_failed' => 'Could not write the request (signal directory not writable).',
|
||||
'no_host_response' => 'No response from the host. Is the release watcher running?',
|
||||
|
||||
// publish + yank (B2)
|
||||
'publish' => 'Publish',
|
||||
'deploy_public' => 'Deploy to Public (:tag)',
|
||||
'promote_stable' => 'Promote to Stable (:v)',
|
||||
'publish_hint' => 'Triggers a GitHub workflow that promotes the version to the public repo.',
|
||||
'public_confirm_title' => 'Make the beta public?',
|
||||
'public_confirm_body' => 'Promotes :tag to the public beta channel (clusev/clusev).',
|
||||
'public_action' => 'Publish',
|
||||
'public_dispatched' => 'Public promotion triggered: :tag.',
|
||||
'stable_confirm_title' => 'Release as stable?',
|
||||
'stable_confirm_body' => 'Finalises the beta as stable :v in the public stable channel.',
|
||||
'stable_action' => 'Release stable',
|
||||
'stable_dispatched' => 'Stable promotion triggered: :v.',
|
||||
'promote_failed' => 'Dispatch failed — check the token scope (Actions: Write).',
|
||||
'yank_title' => 'Retract a public release',
|
||||
'yank' => 'Yank',
|
||||
'yank_empty' => 'No public tags available.',
|
||||
'yank_unknown' => 'That tag is no longer public.',
|
||||
'yank_confirm_title' => 'Retract release?',
|
||||
'yank_confirm_body' => 'Deletes tag :tag on clusev/clusev — users stop being offered it. History stays.',
|
||||
'yank_action' => 'Retract',
|
||||
'yank_dispatched' => 'Yank triggered: :tag.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -114,6 +114,40 @@
|
|||
<p class="mt-3 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('release.hint') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($isBeta)
|
||||
{{-- Publish the current beta --}}
|
||||
<div>
|
||||
<p class="mb-2.5 font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('release.publish') }}</p>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<x-btn variant="primary" wire:click="confirmDeployPublic" wire:loading.attr="disabled">
|
||||
{{ __('release.deploy_public', ['tag' => $current]) }}
|
||||
</x-btn>
|
||||
<x-btn variant="accent" wire:click="confirmPromoteStable" wire:loading.attr="disabled">
|
||||
{{ __('release.promote_stable', ['v' => $stableTarget]) }}
|
||||
</x-btn>
|
||||
</div>
|
||||
<p class="mt-3 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('release.publish_hint') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Yank a public release --}}
|
||||
<details class="group rounded-lg border border-line bg-surface">
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between px-4 py-3 font-mono text-[11px] uppercase tracking-wider text-ink-3">
|
||||
{{ __('release.yank_title') }}
|
||||
<x-icon name="chevron-right" class="h-4 w-4 shrink-0 transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<div class="border-t border-line px-4 py-3">
|
||||
@forelse ($publicTags as $tag)
|
||||
<div class="flex items-center justify-between gap-3 py-1.5" wire:key="yank-{{ $tag }}">
|
||||
<span class="font-mono text-xs text-ink-2">{{ $tag }}</span>
|
||||
<x-btn variant="danger-soft" wire:click="confirmYank('{{ $tag }}')" wire:loading.attr="disabled">{{ __('release.yank') }}</x-btn>
|
||||
</div>
|
||||
@empty
|
||||
<p class="font-mono text-[11px] text-ink-4">{{ __('release.yank_empty') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{{-- Right: live pipeline rail --}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\PromotionService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PromotionServiceTest extends TestCase
|
||||
{
|
||||
// Neutral example slugs — never the real private staging slug in a tracked test.
|
||||
private const STAGING = 'acme/staging';
|
||||
|
||||
private const PUBLIC_SLUG = 'acme/public';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush(); // publicTags() is cached — never inherit a cached result between tests
|
||||
config()->set('clusev.github_token', 'tok_secret');
|
||||
config()->set('clusev.staging_slug', self::STAGING);
|
||||
config()->set('clusev.public_slug', self::PUBLIC_SLUG);
|
||||
config()->set('clusev.release_branch', 'feat/v1-foundation');
|
||||
}
|
||||
|
||||
public function test_deploy_public_dispatches_promote_public_with_the_tag(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
||||
|
||||
$this->assertTrue(app(PromotionService::class)->deployPublic('v0.10.0-beta1'));
|
||||
|
||||
Http::assertSent(fn ($req) => $req->method() === 'POST'
|
||||
&& str_contains($req->url(), 'repos/acme/staging/actions/workflows/promote-public.yml/dispatches')
|
||||
&& $req['ref'] === 'feat/v1-foundation'
|
||||
&& $req['inputs']['tag'] === 'v0.10.0-beta1'
|
||||
&& $req->hasHeader('Authorization')
|
||||
&& ! str_contains($req->url(), 'tok_secret'));
|
||||
}
|
||||
|
||||
public function test_promote_stable_dispatches_with_both_tags(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
||||
|
||||
$this->assertTrue(app(PromotionService::class)->promoteStable('v0.10.0-beta3', 'v0.10.0'));
|
||||
|
||||
Http::assertSent(fn ($req) => str_contains($req->url(), 'promote-stable.yml/dispatches')
|
||||
&& $req['inputs']['betaTag'] === 'v0.10.0-beta3'
|
||||
&& $req['inputs']['stableTag'] === 'v0.10.0');
|
||||
}
|
||||
|
||||
public function test_yank_dispatches_yank_with_the_tag(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
||||
|
||||
$this->assertTrue(app(PromotionService::class)->yank('v0.9.9'));
|
||||
|
||||
Http::assertSent(fn ($req) => str_contains($req->url(), 'yank.yml/dispatches')
|
||||
&& $req['inputs']['tag'] === 'v0.9.9');
|
||||
}
|
||||
|
||||
public function test_dispatch_returns_false_on_a_github_error(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response('forbidden', 403)]);
|
||||
$this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-beta1'));
|
||||
}
|
||||
|
||||
public function test_dispatch_without_a_token_does_not_call_github(): void
|
||||
{
|
||||
config()->set('clusev.github_token', '');
|
||||
Http::fake();
|
||||
|
||||
$this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-beta1'));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_public_tags_reads_the_public_repo_anonymously(): void
|
||||
{
|
||||
Http::fake(['api.github.com/repos/acme/public/tags*' => Http::response([['name' => 'v0.9.9'], ['name' => 'v0.9.8']])]);
|
||||
|
||||
$this->assertSame(['v0.9.9', 'v0.9.8'], app(PromotionService::class)->publicTags());
|
||||
|
||||
Http::assertSent(fn ($req) => str_contains($req->url(), 'repos/acme/public/tags')
|
||||
&& ! $req->hasHeader('Authorization'));
|
||||
}
|
||||
|
||||
public function test_public_tags_is_empty_on_error(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response('boom', 500)]);
|
||||
$this->assertSame([], app(PromotionService::class)->publicTags());
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ namespace Tests\Feature;
|
|||
use App\Livewire\Release\Index;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -21,6 +23,9 @@ class ReleasePageTest extends TestCase
|
|||
parent::setUp();
|
||||
config()->set('clusev.release_controls', true);
|
||||
config()->set('clusev.version', '0.9.58');
|
||||
// Degrade public-tag reads by default so render() makes no live GitHub call; the
|
||||
// promotion tests that need a public repo set clusev.public_slug themselves.
|
||||
config()->set('clusev.public_slug', '');
|
||||
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
||||
$this->dir = storage_path('app/restart-signal');
|
||||
@mkdir($this->dir, 0775, true);
|
||||
|
|
@ -111,4 +116,73 @@ class ReleasePageTest extends TestCase
|
|||
config()->set('clusev.version', '0.9.60');
|
||||
Livewire::test(Index::class)->assertViewHas('pipeline', null);
|
||||
}
|
||||
|
||||
public function test_deploy_public_dispatches_and_audits_for_a_beta(): void
|
||||
{
|
||||
config()->set('clusev.version', '0.10.0-beta1');
|
||||
config()->set('clusev.github_token', 'tok');
|
||||
config()->set('clusev.staging_slug', 'acme/staging');
|
||||
config()->set('clusev.release_branch', 'feat/v1-foundation');
|
||||
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
||||
|
||||
$token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']);
|
||||
ConfirmToken::confirm($token); // the modal confirmation step
|
||||
Livewire::test(Index::class)->call('applyDeployPublic', $token);
|
||||
|
||||
$this->assertTrue(AuditEvent::where('action', 'deploy.public')->exists());
|
||||
}
|
||||
|
||||
public function test_promote_stable_uses_the_beta_base_as_the_stable_tag(): void
|
||||
{
|
||||
config()->set('clusev.version', '0.10.0-beta3');
|
||||
config()->set('clusev.github_token', 'tok');
|
||||
config()->set('clusev.staging_slug', 'acme/staging');
|
||||
config()->set('clusev.release_branch', 'feat/v1-foundation');
|
||||
Http::fake(['api.github.com/*' => Http::response('', 204)]);
|
||||
|
||||
$token = ConfirmToken::issue('releaseStable', ['betaTag' => 'v0.10.0-beta3']);
|
||||
ConfirmToken::confirm($token); // the modal confirmation step
|
||||
Livewire::test(Index::class)->call('applyPromoteStable', $token);
|
||||
|
||||
Http::assertSent(fn ($req) => str_contains($req->url(), 'promote-stable.yml/dispatches')
|
||||
&& $req['inputs']['stableTag'] === 'v0.10.0');
|
||||
$this->assertTrue(AuditEvent::where('action', 'deploy.stable')->exists());
|
||||
}
|
||||
|
||||
public function test_yank_only_dispatches_for_a_tag_that_exists_on_public(): void
|
||||
{
|
||||
config()->set('clusev.github_token', 'tok');
|
||||
config()->set('clusev.staging_slug', 'acme/staging');
|
||||
config()->set('clusev.public_slug', 'acme/public');
|
||||
config()->set('clusev.release_branch', 'feat/v1-foundation');
|
||||
Http::fake([
|
||||
'api.github.com/repos/acme/public/tags*' => Http::response([['name' => 'v0.9.9']]),
|
||||
'api.github.com/*/dispatches' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
// a tag NOT in publicTags is refused (no dispatch, no audit)
|
||||
$bad = ConfirmToken::issue('releaseYank', ['tag' => 'v9.9.9']);
|
||||
ConfirmToken::confirm($bad); // the modal confirmation step
|
||||
Livewire::test(Index::class)->call('applyYank', $bad);
|
||||
$this->assertFalse(AuditEvent::where('action', 'deploy.yank')->exists());
|
||||
|
||||
// the real public tag is dispatched + audited
|
||||
$good = ConfirmToken::issue('releaseYank', ['tag' => 'v0.9.9']);
|
||||
ConfirmToken::confirm($good); // the modal confirmation step
|
||||
Livewire::test(Index::class)->call('applyYank', $good);
|
||||
$this->assertTrue(AuditEvent::where('action', 'deploy.yank')->exists());
|
||||
}
|
||||
|
||||
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.public_slug', ''); // degrade: no public-tags read on render
|
||||
Http::fake();
|
||||
|
||||
$token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']);
|
||||
ConfirmToken::confirm($token); // the modal confirmation step
|
||||
Livewire::test(Index::class)->call('applyDeployPublic', $token);
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue