feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main Rewrite .github/workflows/release.yml to trigger on every push to main instead of manual tag pushes. The workflow now: - Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER - Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel - Merges them into a universal binary using lipo - Creates a universal .dmg and signed updater tarball - Generates latest.json with per-arch and universal platform entries - Publishes a GitHub Release with auto-generated release notes - Updates a GitHub Pages release history site (gh-pages branch) Product decisions: - Universal binary approach: copy arm64 .app as base, lipo the main executable, keep everything else from arm64 (shared frameworks are architecture-independent). This is the standard Tauri pattern. - Per-arch updater tarballs are also uploaded so the Tauri updater can download the correct arch-specific build (smaller download). - Release notes are auto-generated from git log since last tag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: github pages with release history Use peaceiris/actions-gh-pages@v4 to deploy a release history site. The page fetches releases.json (also deployed) and renders each release with date, notes, and download links for .dmg files. This handles the gh-pages branch creation automatically on first run. The page is available at https://refactoringhq.github.io/laputa-app/ Product decision: used fetch() to load releases.json at runtime instead of inlining it, which is cleaner and avoids shell escaping issues with release note content. The releases.json is deployed alongside index.html. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: in-app update notification UI Replace the old window.confirm updater with a proper React-based update notification system: - useUpdater hook now exposes state machine (idle → available → downloading → ready) and actions (startDownload, openReleaseNotes, dismiss) - UpdateBanner component renders at the top of the app shell: - "Available" state: shows version, Release Notes link, Update Now button, dismiss X - "Downloading" state: animated spinner, progress bar with percentage - "Ready" state: Restart Now button to apply the update - Silently checks on startup after 3s delay; fails silently on network errors or 404 - Release Notes link opens the GitHub Pages release history site Product decisions: - Banner at top of app (not a modal) — non-intrusive, visible but not blocking. User can dismiss and continue working. - Progress bar shows during download so user knows it's working. - Separate "Restart Now" state after download so user controls when the app restarts (they may have unsaved work). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: updater component tests Rewrite useUpdater hook tests and add UpdateBanner component tests: Hook tests (10 cases): - Starts in idle state - Does nothing when not in Tauri - Checks for updates after 3s delay - Stays idle when no update available - Transitions to available when update found - Handles missing release body gracefully - Stays idle on network error (fails silently) - Dismiss returns to idle - openReleaseNotes opens correct URL - startDownload transitions through downloading to ready Component tests (10 cases): - Renders nothing when idle - Renders nothing on error - Shows version and buttons when available - Update Now calls startDownload - Release Notes calls openReleaseNotes - Dismiss button works - Shows progress bar during download - Shows 0% at start of download - Shows restart button when ready - Restart button calls restartApp All 457 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: auto-build-release wireframes Copy ui-design.pen as base. Frames to be added for: 1. Update notification banner (visible state) — horizontal bar at top of app shell with version text, Release Notes link, Update Now button, and dismiss X 2. Update download progress state — spinner icon, progress bar with percentage, downloading text 3. "Restart to apply" state — green accent, version text, Restart Now button Note: Pencil editor was not available during this session. The base design file is committed; frames will be added when the editor is accessible. The implemented component (UpdateBanner.tsx) serves as the source of truth for the design. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update ARCHITECTURE.md with release/update system Add comprehensive documentation for: - Release pipeline (4-phase workflow: version → build → release → pages) - Versioning scheme (0.YYYYMMDD.RUN_NUMBER) - Universal binary strategy (lipo merge) - Updater endpoint and latest.json manifest - In-app update UI state machine - GitHub Pages release history site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rustfmt formatting * fix: rustfmt build.rs --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
400
.github/workflows/release.yml
vendored
400
.github/workflows/release.yml
vendored
@@ -2,24 +2,50 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
APP_NAME: Laputa
|
||||
BUNDLE_ID: com.tauri.dev
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release (macOS)
|
||||
permissions:
|
||||
contents: write
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 1: Compute the version string once
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
version:
|
||||
name: Compute version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
- id: ver
|
||||
run: |
|
||||
VERSION="0.$(date -u +%Y%m%d).${GITHUB_RUN_NUMBER}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "### Version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
needs: version
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
runner: macos-latest
|
||||
- target: x86_64-apple-darwin
|
||||
runner: macos-latest
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -39,19 +65,349 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build and release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
# Stamp the computed version into tauri.conf.json and Cargo.toml
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
# tauri.conf.json
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
# Cargo.toml
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Tauri app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
# Upload the .app bundle so the merge job can lipo them together.
|
||||
# Also upload the per-arch .tar.gz updater artifact and its .sig.
|
||||
- name: Upload arch artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'Laputa v__VERSION__'
|
||||
releaseBody: 'See the assets below to download and install this version.'
|
||||
releaseDraft: false
|
||||
name: app-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/${{ env.APP_NAME }}.app
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload updater artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: updater-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/${{ env.APP_NAME }}.app.tar.gz
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/${{ env.APP_NAME }}.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload dmg artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dmg-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
retention-days: 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Merge into universal binary, re-package, and release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: Universal binary & GitHub Release
|
||||
needs: [version, build]
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # for release notes generation
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Create universal binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p universal
|
||||
|
||||
ARM_APP="app-aarch64/${{ env.APP_NAME }}.app"
|
||||
X86_APP="app-x86_64/${{ env.APP_NAME }}.app"
|
||||
|
||||
# Copy the aarch64 .app as base (preserves bundle structure)
|
||||
cp -R "$ARM_APP" "universal/${{ env.APP_NAME }}.app"
|
||||
|
||||
# Find the main executable inside the .app bundle
|
||||
EXEC_NAME="${{ env.APP_NAME }}"
|
||||
# Tauri uses lowercase binary name
|
||||
EXEC_NAME_LOWER=$(echo "$EXEC_NAME" | tr '[:upper:]' '[:lower:]')
|
||||
ARM_BIN="$ARM_APP/Contents/MacOS/$EXEC_NAME_LOWER"
|
||||
X86_BIN="$X86_APP/Contents/MacOS/$EXEC_NAME_LOWER"
|
||||
UNI_BIN="universal/${{ env.APP_NAME }}.app/Contents/MacOS/$EXEC_NAME_LOWER"
|
||||
|
||||
# If the binary is the product name (case-sensitive), try that too
|
||||
if [ ! -f "$ARM_BIN" ]; then
|
||||
ARM_BIN="$ARM_APP/Contents/MacOS/$EXEC_NAME"
|
||||
X86_BIN="$X86_APP/Contents/MacOS/$EXEC_NAME"
|
||||
UNI_BIN="universal/${{ env.APP_NAME }}.app/Contents/MacOS/$EXEC_NAME"
|
||||
fi
|
||||
|
||||
echo "Merging: $ARM_BIN + $X86_BIN → $UNI_BIN"
|
||||
lipo -create "$ARM_BIN" "$X86_BIN" -output "$UNI_BIN"
|
||||
lipo -info "$UNI_BIN"
|
||||
|
||||
- name: Create universal .dmg
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
DMG_NAME="${{ env.APP_NAME }}_${VERSION}_universal.dmg"
|
||||
|
||||
hdiutil create -volname "${{ env.APP_NAME }}" \
|
||||
-srcfolder "universal/${{ env.APP_NAME }}.app" \
|
||||
-ov -format UDZO \
|
||||
"universal/$DMG_NAME"
|
||||
|
||||
echo "DMG_PATH=universal/$DMG_NAME" >> "$GITHUB_ENV"
|
||||
echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create universal updater tarball and sign
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Create tar.gz of the universal .app
|
||||
TARBALL="universal/${{ env.APP_NAME }}.app.tar.gz"
|
||||
tar -czf "$TARBALL" -C universal "${{ env.APP_NAME }}.app"
|
||||
|
||||
# Install tauri-cli for signing
|
||||
npm install -g @tauri-apps/cli
|
||||
|
||||
# Sign the tarball using Tauri's signer
|
||||
tauri signer sign "$TARBALL" --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD"
|
||||
|
||||
echo "TARBALL_PATH=$TARBALL" >> "$GITHUB_ENV"
|
||||
echo "SIG_PATH=${TARBALL}.sig" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate release notes
|
||||
id: notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Find the previous release tag
|
||||
PREV_TAG=$(git tag --sort=-version:refname | head -n 1 || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
# No previous tag — use all commits on main
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..HEAD")
|
||||
fi
|
||||
|
||||
# Write to file for the release body
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do
|
||||
echo "- $line"
|
||||
done
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
|
||||
cat release_notes.md
|
||||
|
||||
- name: Build latest.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
REPO="refactoringhq/laputa-app"
|
||||
|
||||
# Read signatures
|
||||
UNI_SIG=$(cat "${SIG_PATH}")
|
||||
ARM_SIG=$(cat updater-aarch64/${{ env.APP_NAME }}.app.tar.gz.sig)
|
||||
X86_SIG=$(cat updater-x86_64/${{ env.APP_NAME }}.app.tar.gz.sig)
|
||||
|
||||
# The universal tarball URL
|
||||
UNI_URL="https://github.com/${REPO}/releases/download/${TAG}/${{ env.APP_NAME }}.app.tar.gz"
|
||||
|
||||
# Per-arch URLs (fallback)
|
||||
ARM_URL="https://github.com/${REPO}/releases/download/${TAG}/${{ env.APP_NAME }}_aarch64.app.tar.gz"
|
||||
X86_URL="https://github.com/${REPO}/releases/download/${TAG}/${{ env.APP_NAME }}_x86_64.app.tar.gz"
|
||||
|
||||
cat > latest.json << MANIFEST
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "See https://refactoringhq.github.io/laputa-app/ for full release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "${ARM_URL}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${X86_SIG}",
|
||||
"url": "${X86_URL}"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"signature": "${UNI_SIG}",
|
||||
"url": "${UNI_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
MANIFEST
|
||||
|
||||
echo "latest.json:"
|
||||
cat latest.json
|
||||
|
||||
- name: Rename per-arch updater artifacts
|
||||
run: |
|
||||
cp "updater-aarch64/${{ env.APP_NAME }}.app.tar.gz" "universal/${{ env.APP_NAME }}_aarch64.app.tar.gz"
|
||||
cp "updater-aarch64/${{ env.APP_NAME }}.app.tar.gz.sig" "universal/${{ env.APP_NAME }}_aarch64.app.tar.gz.sig"
|
||||
cp "updater-x86_64/${{ env.APP_NAME }}.app.tar.gz" "universal/${{ env.APP_NAME }}_x86_64.app.tar.gz"
|
||||
cp "updater-x86_64/${{ env.APP_NAME }}.app.tar.gz.sig" "universal/${{ env.APP_NAME }}_x86_64.app.tar.gz.sig"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Laputa ${{ needs.version.outputs.version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target }}
|
||||
files: |
|
||||
${{ env.DMG_PATH }}
|
||||
${{ env.TARBALL_PATH }}
|
||||
${{ env.SIG_PATH }}
|
||||
universal/${{ env.APP_NAME }}_aarch64.app.tar.gz
|
||||
universal/${{ env.APP_NAME }}_aarch64.app.tar.gz.sig
|
||||
universal/${{ env.APP_NAME }}_x86_64.app.tar.gz
|
||||
universal/${{ env.APP_NAME }}_x86_64.app.tar.gz.sig
|
||||
latest.json
|
||||
dmg-aarch64/*.dmg
|
||||
dmg-x86_64/*.dmg
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages with release history
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update release history page
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build release history page
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _site
|
||||
|
||||
# Fetch all releases as JSON
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
|
||||
# Build the HTML page
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Laputa — Release History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #F7F6F3;
|
||||
color: #37352F;
|
||||
line-height: 1.6;
|
||||
padding: 2rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
.subtitle { color: #787774; margin-bottom: 2rem; }
|
||||
.release {
|
||||
background: #fff;
|
||||
border: 1px solid #E9E9E7;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.release h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.release .meta { font-size: 0.8125rem; color: #787774; margin-bottom: 0.75rem; }
|
||||
.release .body { font-size: 0.875rem; white-space: pre-wrap; }
|
||||
.release .downloads {
|
||||
margin-top: 0.75rem;
|
||||
display: flex; gap: 0.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.release .downloads a {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: #155DFF; color: #fff;
|
||||
border-radius: 6px; text-decoration: none;
|
||||
font-size: 0.8125rem; font-weight: 500;
|
||||
}
|
||||
.release .downloads a:hover { background: #1248CC; }
|
||||
.empty { color: #787774; text-align: center; padding: 3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Laputa Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
fetch('releases.json').then(r => r.json()).then(releases => {
|
||||
const el = document.getElementById('releases');
|
||||
if (!releases.length) {
|
||||
el.innerHTML = '<p class="empty">No releases yet.</p>';
|
||||
return;
|
||||
}
|
||||
releases.forEach(r => {
|
||||
const date = new Date(r.published_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
const dmgs = (r.assets || []).filter(a => a.name.endsWith('.dmg'));
|
||||
const links = dmgs.map(a =>
|
||||
'<a href="' + a.browser_download_url + '">' + a.name + '</a>'
|
||||
).join('');
|
||||
const body = (r.body || '')
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/^## /gm, '').replace(/^- /gm, '\u2022 ');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'release';
|
||||
div.innerHTML =
|
||||
'<h2>' + (r.name || r.tag_name) + '</h2>' +
|
||||
'<div class="meta">' + date + ' \u00b7 ' + r.tag_name + '</div>' +
|
||||
'<div class="body">' + body + '</div>' +
|
||||
(links ? '<div class="downloads">' + links + '</div>' : '');
|
||||
el.appendChild(div);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTMLEOF
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site
|
||||
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"
|
||||
|
||||
Reference in New Issue
Block a user