CI: cut releases by hand instead of on every merge to main #79
No files matched your search
@@ -1,20 +1,22 @@
|
||||
name: Build App
|
||||
|
||||
# **Releases are cut by hand.** This runs only when someone dispatches it
|
||||
# (Actions -> Build App -> Run workflow). Merging to main does not release:
|
||||
# main can carry finished-but-dormant work (a feature landing in phases) and
|
||||
# nothing reaches users until a release is cut on purpose.
|
||||
#
|
||||
# Dispatched on `main` it releases: Gitea and GitHub releases, the version tag,
|
||||
# the Linux update channel, and then the container images' `:latest` (see the
|
||||
# create-tag job). Dispatched on any other branch it is a dry run that builds,
|
||||
# signs and verifies all three platforms and publishes nothing, which is how to
|
||||
# exercise Windows signing before a merge. Every publishing step and job
|
||||
# carries the same `if:` (dispatch event *and* ref main); keep them in step if
|
||||
# one is ever added.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
- "scripts/windows-*.ps1"
|
||||
workflow_dispatch:
|
||||
|
||||
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
||||
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
||||
# the same three and publishes them. Six OS builds per push, one set of which
|
||||
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||
# Deliberately **not** on pull_request: build-app-preview.yml carries the PR
|
||||
# check (tests plus all three platforms), and this workflow is releases only.
|
||||
|
||||
env:
|
||||
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
|
||||
@@ -262,7 +264,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
@@ -288,7 +290,7 @@ jobs:
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
@@ -340,7 +342,7 @@ jobs:
|
||||
# from the versioned release above because the updater's URL must never
|
||||
# move, and `releases/latest` does.
|
||||
- name: Publish the Linux update channel
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -429,7 +431,7 @@ jobs:
|
||||
ls -la artifacts/
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
@@ -455,7 +457,7 @@ jobs:
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
@@ -717,7 +719,7 @@ jobs:
|
||||
dir artifacts\
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
shell: powershell
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -741,8 +743,9 @@ jobs:
|
||||
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
||||
Write-Host "Release $tag not found, creating"
|
||||
$body = @{
|
||||
tag_name = $tag
|
||||
name = "Triple-C v$env:VERSION (Windows)"
|
||||
tag_name = $tag
|
||||
target_commitish = $env:COMMIT_SHA
|
||||
name = "Triple-C v$env:VERSION (Windows)"
|
||||
body = "Automated build from commit $env:COMMIT_SHA"
|
||||
} | ConvertTo-Json
|
||||
$release = Invoke-RestMethod -Method Post -Headers $headers `
|
||||
@@ -778,7 +781,7 @@ jobs:
|
||||
create-tag:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -788,24 +791,56 @@ jobs:
|
||||
- name: Create version tag
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
set -euo pipefail
|
||||
TAG="v${VERSION}"
|
||||
echo "Creating tag ${TAG}..."
|
||||
|
||||
# Create annotated tag via Gitea API
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
|
||||
# Usually already there: creating the Linux release creates its tag,
|
||||
# at this commit (`target_commitish`). Create it if not, then insist
|
||||
# it points here, because the container step below builds whatever
|
||||
# the tag names.
|
||||
if ! curl -fsS -o tag.json -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}"; then
|
||||
echo "Creating tag ${TAG} at ${SHA}"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${SHA}\", \"message\": \"Release ${TAG}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags" > tag.json
|
||||
fi
|
||||
TAGGED=$(python3 -c "import json; print(json.load(open('tag.json'))['commit']['sha'])")
|
||||
if [ "${TAGGED}" != "${SHA}" ]; then
|
||||
echo "Tag ${TAG} points at ${TAGGED}, not the released commit ${SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag ${TAG} is at ${SHA}"
|
||||
|
||||
echo "Tag ${TAG} created successfully"
|
||||
# The containers' `:latest` is what every installed app pulls, so it
|
||||
# moves with an app release and at no other time (pushes to main only
|
||||
# publish `:edge` and `:<sha>`). Each container workflow builds the tag
|
||||
# it is handed, not main, so a merge landing mid-release cannot ride
|
||||
# along.
|
||||
- name: Release the container images
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for WORKFLOW in build.yml build-stt.yml; do
|
||||
echo "Dispatching ${WORKFLOW} for v${VERSION}"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"release_tag\": \"v${VERSION}\"}}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/${WORKFLOW}/dispatches"
|
||||
done
|
||||
|
||||
sync-to-github:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
|
||||
@@ -11,6 +11,12 @@ on:
|
||||
paths:
|
||||
- "stt-container/**"
|
||||
- ".gitea/workflows/build-stt.yml"
|
||||
# Dispatched by build-app.yml when a release is cut, with the release's tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
|
||||
required: false
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -22,6 +28,65 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# **`:latest` is what installed apps pull, so only a release moves it.**
|
||||
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
|
||||
# image by hand); a pull request publishes nothing. The weekly rebuild
|
||||
# and a release dispatch build a release tag and publish it as `:latest`
|
||||
# — the tag, not main, so unreleased container changes never reach users.
|
||||
- name: Pick the source and the tags to publish
|
||||
id: src
|
||||
env:
|
||||
EVENT: ${{ gitea.event_name }}
|
||||
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
|
||||
REF: ${{ gitea.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$EVENT" in
|
||||
push|pull_request)
|
||||
CHANNEL=edge
|
||||
;;
|
||||
schedule|workflow_dispatch)
|
||||
CHANNEL=latest
|
||||
# `:latest` reaches every user, so it only comes from main: a
|
||||
# dispatch from another branch, or a tag cut on one, is refused.
|
||||
# An older release tag is allowed on purpose — that is a rollback.
|
||||
if [ "$REF" != "refs/heads/main" ]; then
|
||||
echo "Refusing to publish :latest from $REF (only main)" >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --tags --force
|
||||
git fetch origin main
|
||||
if [ -z "${RELEASE_TAG:-}" ]; then
|
||||
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
fi
|
||||
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Not a release tag: '$RELEASE_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
|
||||
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "refs/tags/$RELEASE_TAG"
|
||||
echo "Building release $RELEASE_TAG"
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected event $EVENT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
SHA=$(git rev-parse HEAD)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
|
||||
echo "ghcr.io/shadowdao/triple-c-stt:$CHANNEL"
|
||||
echo "ghcr.io/shadowdao/triple-c-stt:$SHA"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -49,12 +114,10 @@ jobs:
|
||||
context: ./stt-container
|
||||
file: ./stt-container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ gitea.event_name == 'push' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-stt:latest
|
||||
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||
# A pull_request run only validates; see the tag step for what each
|
||||
# other event publishes.
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.src.outputs.tags }}
|
||||
# The gha cache is served by the runner that picks the job up, and
|
||||
# is unreachable from the job on some of ours (the home box's
|
||||
# firewalled per-job bridge; ephemeral runners). Import failures
|
||||
|
||||
@@ -13,9 +13,15 @@ on:
|
||||
- ".gitea/workflows/build.yml"
|
||||
# Weekly rebuild with no commit needed, so OS security fixes published since
|
||||
# the last push reach the image (see APT_REFRESH in container/Dockerfile).
|
||||
# Scheduled runs use the default branch, i.e. main.
|
||||
# It rebuilds the newest *release*, not main, because it moves `:latest`.
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
# Dispatched by build-app.yml when a release is cut, with the release's tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
|
||||
required: false
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -27,6 +33,65 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# **`:latest` is what installed apps pull, so only a release moves it.**
|
||||
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
|
||||
# image by hand); a pull request publishes nothing. The weekly rebuild
|
||||
# and a release dispatch build a release tag and publish it as `:latest`
|
||||
# — the tag, not main, so unreleased container changes never reach users.
|
||||
- name: Pick the source and the tags to publish
|
||||
id: src
|
||||
env:
|
||||
EVENT: ${{ gitea.event_name }}
|
||||
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
|
||||
REF: ${{ gitea.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$EVENT" in
|
||||
push|pull_request)
|
||||
CHANNEL=edge
|
||||
;;
|
||||
schedule|workflow_dispatch)
|
||||
CHANNEL=latest
|
||||
# `:latest` reaches every user, so it only comes from main: a
|
||||
# dispatch from another branch, or a tag cut on one, is refused.
|
||||
# An older release tag is allowed on purpose — that is a rollback.
|
||||
if [ "$REF" != "refs/heads/main" ]; then
|
||||
echo "Refusing to publish :latest from $REF (only main)" >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --tags --force
|
||||
git fetch origin main
|
||||
if [ -z "${RELEASE_TAG:-}" ]; then
|
||||
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
fi
|
||||
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Not a release tag: '$RELEASE_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
|
||||
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "refs/tags/$RELEASE_TAG"
|
||||
echo "Building release $RELEASE_TAG"
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected event $EVENT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
SHA=$(git rev-parse HEAD)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
|
||||
echo "ghcr.io/shadowdao/triple-c-sandbox:$CHANNEL"
|
||||
echo "ghcr.io/shadowdao/triple-c-sandbox:$SHA"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -81,16 +146,12 @@ jobs:
|
||||
context: ./container
|
||||
file: ./container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# Publish from main pushes and the weekly scheduled rebuild (which
|
||||
# runs on main); a pull_request run only validates.
|
||||
# A pull_request run only validates; see the tag step for what each
|
||||
# other event publishes.
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
APT_REFRESH=${{ steps.apt.outputs.stamp }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||
tags: ${{ steps.src.outputs.tags }}
|
||||
# `ignore-error` is what stops a cache failure failing a build that
|
||||
# already succeeded. act_runner emulates the GitHub Actions cache
|
||||
# service on the runner host's LAN address, and the `docker-container`
|
||||
|
||||
@@ -907,6 +907,25 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
|
||||
|
||||
## Packaging
|
||||
|
||||
### Releases are cut by hand
|
||||
|
||||
**Merging to `main` does not release anything users get.** A release is cut by dispatching
|
||||
`build-app.yml` on `main` (Actions -> Build App -> Run workflow). That one run publishes the Gitea
|
||||
and GitHub releases for all three platforms, the `vX.Y.Z` tag pinned to the dispatched commit, and
|
||||
the Linux update channel. Its last step dispatches `build.yml` and `build-stt.yml` with the new tag,
|
||||
and those rebuild that tag and move `:latest`, which is what installed apps pull.
|
||||
|
||||
- Dispatching `build-app.yml` on any other branch is a **dry run**. It builds, signs and verifies,
|
||||
and publishes nothing. Every publishing step and job is gated on
|
||||
`gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'`, so give any new
|
||||
publishing step the same `if:`.
|
||||
- A push to `main` that touches `container/` or `stt-container/` publishes only `:edge` and
|
||||
`:<sha>`, never `:latest`. The weekly scheduled sandbox rebuild (OS security fixes) rebuilds the
|
||||
**newest release tag**, not main.
|
||||
- This exists so that main can carry dormant work (a feature landing in phases) without each
|
||||
merge shipping. Until the gate, every merge to main touching `app/` released, and every merge
|
||||
touching `container/` moved `:latest`.
|
||||
|
||||
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
|
||||
build and publish for an audience the AppImage already serves, and neither could self-update. The
|
||||
@@ -952,8 +971,8 @@ quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml
|
||||
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
|
||||
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
|
||||
secret available there is available to whoever can push a branch. To exercise signing before a
|
||||
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
|
||||
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
|
||||
merge, dispatch `build-app.yml` on the branch. Off `main` it builds, signs and verifies
|
||||
without releasing (see Releases above).
|
||||
|
||||
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
|
||||
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
|
||||
@@ -986,8 +1005,8 @@ app"), never by the preview workflow, never echoed, and never on a command line.
|
||||
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
|
||||
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
|
||||
repo can reach the secrets through a workflow file, so repo write access is the boundary.
|
||||
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
|
||||
release only ever comes from a merged, visible change. The
|
||||
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), and only a
|
||||
dispatch on `main` publishes, so a signed release only ever comes from a merged, visible change. The
|
||||
Azure side should hold the rest: an app registration with only the signer role on this one
|
||||
certificate profile, and a client secret with an expiry. Four things are load-bearing:
|
||||
|
||||
|
||||
Reference in new issue
Block a user