Fix hung Electron client packages on cross-arch (linux-arm64)
Release rolling / release (push) Failing after 3m18s
Release rolling / release (push) Failing after 3m18s
rebuildConfig:false still ran @electron/rebuild (spread of false is a no-op), which can stall for many minutes when packaging arm64 on x64 CI under "Finalizing package". Use onlyModules:[] to truly skip rebuilds, pre-download Electron zips with timeouts before forge package, point packager at electronZipDir, and kill any single host package after 8m.
This commit is contained in:
@@ -158,14 +158,17 @@ jobs:
|
|||||||
PEARDOCK_CLIENT_HOSTS: ${{ github.event.inputs.hosts || 'linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64,win32-arm64' }}
|
PEARDOCK_CLIENT_HOSTS: ${{ github.event.inputs.hosts || 'linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64,win32-arm64' }}
|
||||||
PEARDOCK_SKIP_CLIENT: ${{ github.event.inputs.skip_client == 'true' && '1' || '0' }}
|
PEARDOCK_SKIP_CLIENT: ${{ github.event.inputs.skip_client == 'true' && '1' || '0' }}
|
||||||
RELEASE_TAG: rolling
|
RELEASE_TAG: rolling
|
||||||
# Prefer prebuilds; skip electron-rebuild (main "Finalizing package" stall)
|
# Prefer prebuilds; skip electron-rebuild (onlyModules:[] in forge.config)
|
||||||
npm_config_build_from_source: 'false'
|
npm_config_build_from_source: 'false'
|
||||||
PEARDOCK_SKIP_REBUILD: '1'
|
PEARDOCK_SKIP_REBUILD: '1'
|
||||||
# Reuse Electron downloads across the 6 client hosts
|
# Reuse Electron downloads; make.cjs predownloads all host zips first
|
||||||
ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron
|
ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron
|
||||||
electron_config_cache: ${{ github.workspace }}/.cache/electron
|
electron_config_cache: ${{ github.workspace }}/.cache/electron
|
||||||
# electron-packager / forge download target Electron builds
|
PEARDOCK_ELECTRON_DOWNLOAD_TIMEOUT_MS: '180000'
|
||||||
ELECTRON_GET_USE_PROXY: ${{ env.ELECTRON_GET_USE_PROXY || '' }}
|
# Kill a single hung forge package (default 10m) so the job fails visibly
|
||||||
|
PEARDOCK_CLIENT_TIMEOUT_MS: '480000'
|
||||||
|
ELECTRON_GET_USE_PROXY: '0'
|
||||||
|
CI: 'true'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||||
@@ -180,9 +183,10 @@ jobs:
|
|||||||
export GITEA_URL="${GITHUB_SERVER_URL:-}"
|
export GITEA_URL="${GITHUB_SERVER_URL:-}"
|
||||||
fi
|
fi
|
||||||
mkdir -p "${ELECTRON_CACHE:-$GITHUB_WORKSPACE/.cache/electron}"
|
mkdir -p "${ELECTRON_CACHE:-$GITHUB_WORKSPACE/.cache/electron}"
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/.cache/electron-zips"
|
||||||
echo "Server hosts: $PEARDOCK_SERVER_HOSTS"
|
echo "Server hosts: $PEARDOCK_SERVER_HOSTS"
|
||||||
echo "Client hosts: $PEARDOCK_CLIENT_HOSTS"
|
echo "Client hosts: $PEARDOCK_CLIENT_HOSTS"
|
||||||
chmod +x scripts/gitea-rolling-release.sh scripts/bare-standalone.cjs scripts/make.cjs
|
chmod +x scripts/gitea-rolling-release.sh scripts/bare-standalone.cjs scripts/make.cjs scripts/predownload-electron.cjs
|
||||||
bash scripts/gitea-rolling-release.sh
|
bash scripts/gitea-rolling-release.sh
|
||||||
|
|
||||||
- name: Upload dist as workflow artifact (backup)
|
- name: Upload dist as workflow artifact (backup)
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ peardock-peers.json
|
|||||||
peardock-sbom.json
|
peardock-sbom.json
|
||||||
peardock-vault.json
|
peardock-vault.json
|
||||||
server/.env
|
server/.env
|
||||||
|
|
||||||
|
# Local / CI electron zip cache
|
||||||
|
.cache/
|
||||||
|
|||||||
+41
-5
@@ -56,6 +56,7 @@ const IGNORE_PREFIXES = [
|
|||||||
'/workers',
|
'/workers',
|
||||||
'/peardock-branding', // master brand package; runtime copies live in build/ + assets/
|
'/peardock-branding', // master brand package; runtime copies live in build/ + assets/
|
||||||
'/tools', // CI-only binaries (rcodesign, etc.)
|
'/tools', // CI-only binaries (rcodesign, etc.)
|
||||||
|
'/.cache', // electron zip predownload cache
|
||||||
'/build/stubs',
|
'/build/stubs',
|
||||||
'/build/shims',
|
'/build/shims',
|
||||||
'/ROADMAP.md',
|
'/ROADMAP.md',
|
||||||
@@ -164,11 +165,22 @@ function shouldIgnore(file) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Skip electron-rebuild when prebuilds exist (default in CI). Set PEARDOCK_FORCE_REBUILD=1 to rebuild. */
|
/**
|
||||||
|
* Skip @electron/rebuild work during package.
|
||||||
|
* NOTE: rebuildConfig:false still *invokes* rebuild (spread of false is a no-op).
|
||||||
|
* onlyModules: [] is the real "rebuild nothing" switch — critical for cross-arch
|
||||||
|
* CI (linux-x64 packaging linux-arm64) where rebuild can hang for many minutes.
|
||||||
|
* Set PEARDOCK_FORCE_REBUILD=1 to rebuild listed modules.
|
||||||
|
*/
|
||||||
const skipRebuild =
|
const skipRebuild =
|
||||||
process.env.PEARDOCK_FORCE_REBUILD !== '1' &&
|
process.env.PEARDOCK_FORCE_REBUILD !== '1' &&
|
||||||
process.env.PEARDOCK_SKIP_REBUILD !== '0'
|
process.env.PEARDOCK_SKIP_REBUILD !== '0'
|
||||||
|
|
||||||
|
const electronZipDir = path.join(__dirname, '.cache', 'electron-zips')
|
||||||
|
const useElectronZipDir =
|
||||||
|
process.env.PEARDOCK_USE_ELECTRON_ZIP_DIR !== '0' &&
|
||||||
|
fs.existsSync(electronZipDir)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After copy (before asar): belt-and-suspenders strip of foreign prebuilds + junk.
|
* After copy (before asar): belt-and-suspenders strip of foreign prebuilds + junk.
|
||||||
* Ignore should already exclude these; this catches nested paths packager still copied.
|
* Ignore should already exclude these; this catches nested paths packager still copied.
|
||||||
@@ -208,17 +220,28 @@ function stripBuildPath(buildPath) {
|
|||||||
'test',
|
'test',
|
||||||
'docs',
|
'docs',
|
||||||
'peardock-branding',
|
'peardock-branding',
|
||||||
|
'tools',
|
||||||
|
'.cache',
|
||||||
'ROADMAP.md',
|
'ROADMAP.md',
|
||||||
'README.md',
|
'README.md',
|
||||||
]
|
]
|
||||||
for (const rel of junk) rm(rel)
|
for (const rel of junk) rm(rel)
|
||||||
|
|
||||||
// Walk node_modules for prebuilds/<host> dirs
|
// Walk node_modules for prebuilds/<host> dirs (no symlink follow — avoid cycles)
|
||||||
const nm = path.join(buildPath, 'node_modules')
|
const nm = path.join(buildPath, 'node_modules')
|
||||||
if (fs.existsSync(nm)) {
|
if (fs.existsSync(nm)) {
|
||||||
const stack = [nm]
|
const stack = [nm]
|
||||||
|
const seen = new Set()
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
const dir = stack.pop()
|
const dir = stack.pop()
|
||||||
|
let real
|
||||||
|
try {
|
||||||
|
real = fs.realpathSync(dir)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (seen.has(real)) continue
|
||||||
|
seen.add(real)
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
@@ -227,6 +250,8 @@ function stripBuildPath(buildPath) {
|
|||||||
}
|
}
|
||||||
for (const ent of entries) {
|
for (const ent of entries) {
|
||||||
const full = path.join(dir, ent.name)
|
const full = path.join(dir, ent.name)
|
||||||
|
// Skip symlinks entirely (deps sometimes link nested trees)
|
||||||
|
if (ent.isSymbolicLink()) continue
|
||||||
if (!ent.isDirectory()) continue
|
if (!ent.isDirectory()) continue
|
||||||
if (ent.name === 'prebuilds') {
|
if (ent.name === 'prebuilds') {
|
||||||
let hosts
|
let hosts
|
||||||
@@ -246,7 +271,6 @@ function stripBuildPath(buildPath) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Do not descend into prebuilds we already handled
|
|
||||||
if (ent.name === '.bin') continue
|
if (ent.name === '.bin') continue
|
||||||
stack.push(full)
|
stack.push(full)
|
||||||
}
|
}
|
||||||
@@ -280,15 +304,21 @@ module.exports = {
|
|||||||
// Symlink deref + prune walk the whole graph and dominate finalize time
|
// Symlink deref + prune walk the whole graph and dominate finalize time
|
||||||
derefSymlinks: false,
|
derefSymlinks: false,
|
||||||
prune: false,
|
prune: false,
|
||||||
|
// Prefer pre-downloaded zips (scripts/predownload-electron.cjs) so package
|
||||||
|
// never blocks on a silent cross-arch Electron download mid-finalize.
|
||||||
|
...(useElectronZipDir ? { electronZipDir } : {}),
|
||||||
|
// CI: surface packager progress (downloads, asar) instead of quiet hangs
|
||||||
|
quiet: process.env.CI ? false : true,
|
||||||
// Signing is done in postPackage via scripts/sign-macos-app.cjs (ad-hoc or
|
// Signing is done in postPackage via scripts/sign-macos-app.cjs (ad-hoc or
|
||||||
// Developer ID). Leaving packager osxSign off avoids double-sign races;
|
// Developer ID). Leaving packager osxSign off avoids double-sign races;
|
||||||
// a partial linker-signed Electron binary is what causes "damaged / Trash".
|
// a partial linker-signed Electron binary is what causes "damaged / Trash".
|
||||||
osxSign: false,
|
osxSign: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// false = skip @electron/rebuild entirely (use prebuilds). Huge CI win.
|
// onlyModules:[] = rebuild zero modules (forge still calls rebuild, but it
|
||||||
|
// no-ops). rebuildConfig:false does NOT skip the rebuild step.
|
||||||
rebuildConfig: skipRebuild
|
rebuildConfig: skipRebuild
|
||||||
? false
|
? { onlyModules: [], force: false }
|
||||||
: {
|
: {
|
||||||
force: false,
|
force: false,
|
||||||
onlyModules: [
|
onlyModules: [
|
||||||
@@ -316,6 +346,12 @@ module.exports = {
|
|||||||
hooks: {
|
hooks: {
|
||||||
prePackage: async () => {
|
prePackage: async () => {
|
||||||
console.log(`[forge] packaging target host: ${packageHost}`)
|
console.log(`[forge] packaging target host: ${packageHost}`)
|
||||||
|
console.log(
|
||||||
|
`[forge] electronZipDir: ${useElectronZipDir ? electronZipDir : '(none — packager will download)'}`
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`[forge] rebuild: ${skipRebuild ? 'skip (onlyModules:[])' : 'enabled'}`
|
||||||
|
)
|
||||||
if (process.env.PEARDOCK_SKIP_PREPACKAGE_BUNDLE === '1') {
|
if (process.env.PEARDOCK_SKIP_PREPACKAGE_BUNDLE === '1') {
|
||||||
const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs')
|
const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs')
|
||||||
if (fs.existsSync(bundle)) {
|
if (fs.existsSync(bundle)) {
|
||||||
|
|||||||
+57
-1
@@ -10,6 +10,8 @@
|
|||||||
* Env:
|
* Env:
|
||||||
* PEARDOCK_SERVER_HOSTS=linux-x64,darwin-arm64
|
* PEARDOCK_SERVER_HOSTS=linux-x64,darwin-arm64
|
||||||
* PEARDOCK_CLIENT_HOSTS=linux-x64,win32-x64
|
* PEARDOCK_CLIENT_HOSTS=linux-x64,win32-x64
|
||||||
|
* PEARDOCK_CLIENT_TIMEOUT_MS=600000 # kill hung forge package (default 10m)
|
||||||
|
* PEARDOCK_SKIP_ELECTRON_PREDOWNLOAD=1
|
||||||
*/
|
*/
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ const root = path.resolve(__dirname, '..')
|
|||||||
|
|
||||||
const SERVER_HOSTS = parseHostList(process.env.PEARDOCK_SERVER_HOSTS, ALL_64)
|
const SERVER_HOSTS = parseHostList(process.env.PEARDOCK_SERVER_HOSTS, ALL_64)
|
||||||
const CLIENT_HOSTS = parseHostList(process.env.PEARDOCK_CLIENT_HOSTS, ALL_64)
|
const CLIENT_HOSTS = parseHostList(process.env.PEARDOCK_CLIENT_HOSTS, ALL_64)
|
||||||
|
const CLIENT_TIMEOUT_MS = Number(process.env.PEARDOCK_CLIENT_TIMEOUT_MS || 600_000)
|
||||||
|
|
||||||
function run(cmd, args, opts = {}) {
|
function run(cmd, args, opts = {}) {
|
||||||
console.log(`\n$ ${cmd} ${args.join(' ')}\n`)
|
console.log(`\n$ ${cmd} ${args.join(' ')}\n`)
|
||||||
@@ -61,10 +64,30 @@ function makeServer(hosts = SERVER_HOSTS) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function predownloadElectron(hosts) {
|
||||||
|
if (process.env.PEARDOCK_SKIP_ELECTRON_PREDOWNLOAD === '1') {
|
||||||
|
console.log('[make] skip electron predownload (PEARDOCK_SKIP_ELECTRON_PREDOWNLOAD=1)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[make] pre-downloading Electron for client hosts…')
|
||||||
|
run(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(root, 'scripts', 'predownload-electron.cjs')],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PEARDOCK_CLIENT_HOSTS: hosts.join(','),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function makeClient(hosts = CLIENT_HOSTS) {
|
function makeClient(hosts = CLIENT_HOSTS) {
|
||||||
console.log(`[make] client hosts: ${hosts.join(', ')}`)
|
console.log(`[make] client hosts: ${hosts.join(', ')}`)
|
||||||
// Build GUI bundle once; forge prePackage skips rebuild when this is set
|
// Build GUI bundle once; forge prePackage skips rebuild when this is set
|
||||||
npmRun('build:client-bundle')
|
npmRun('build:client-bundle')
|
||||||
|
predownloadElectron(hosts)
|
||||||
|
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
const script = clientNpmScript(host)
|
const script = clientNpmScript(host)
|
||||||
if (!script) {
|
if (!script) {
|
||||||
@@ -82,8 +105,41 @@ function makeClient(hosts = CLIENT_HOSTS) {
|
|||||||
PEARDOCK_PACKAGE_PLATFORM: platform,
|
PEARDOCK_PACKAGE_PLATFORM: platform,
|
||||||
PEARDOCK_PACKAGE_ARCH: arch,
|
PEARDOCK_PACKAGE_ARCH: arch,
|
||||||
PEARDOCK_SKIP_PREPACKAGE_BUNDLE: '1',
|
PEARDOCK_SKIP_PREPACKAGE_BUNDLE: '1',
|
||||||
|
// Surface packager/forge progress in CI logs
|
||||||
|
DEBUG: process.env.DEBUG || (process.env.CI ? 'electron-packager,electron-forge:lifecycle' : ''),
|
||||||
}
|
}
|
||||||
run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script], { env })
|
|
||||||
|
console.log(
|
||||||
|
`[make] client timeout: ${(CLIENT_TIMEOUT_MS / 1000).toFixed(0)}s (PEARDOCK_CLIENT_TIMEOUT_MS)`
|
||||||
|
)
|
||||||
|
const t0 = Date.now()
|
||||||
|
const res = spawnSync(
|
||||||
|
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||||
|
['run', script],
|
||||||
|
{
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env,
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
timeout: CLIENT_TIMEOUT_MS,
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (res.error) {
|
||||||
|
if (res.error.code === 'ETIMEDOUT') {
|
||||||
|
console.error(
|
||||||
|
`[make] FATAL: client package ${host} exceeded ${CLIENT_TIMEOUT_MS}ms — killed. ` +
|
||||||
|
`Often a hung @electron/rebuild or Electron download. Check logs above.`
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
throw res.error
|
||||||
|
}
|
||||||
|
if (res.status !== 0) {
|
||||||
|
console.error(`[make] client package ${host} failed with status ${res.status}`)
|
||||||
|
process.exit(res.status || 1)
|
||||||
|
}
|
||||||
|
console.log(`[make] ok ${host} in ${((Date.now() - t0) / 1000).toFixed(1)}s`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Pre-download Electron binaries for every client host so forge packaging
|
||||||
|
* does not silently hang mid-package on cross-arch GitHub downloads.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/predownload-electron.cjs
|
||||||
|
* PEARDOCK_CLIENT_HOSTS=linux-x64,darwin-arm64 node scripts/predownload-electron.cjs
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* ELECTRON_CACHE / electron_config_cache — cache dir (recommended in CI)
|
||||||
|
* PEARDOCK_ELECTRON_DOWNLOAD_TIMEOUT_MS — per-artifact timeout (default 180000)
|
||||||
|
*/
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const { downloadArtifact } = require('@electron/get')
|
||||||
|
const { ALL_64, parseHostList, hostToElectron } = require('./hosts.cjs')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const electronVersion = require(path.join(root, 'node_modules/electron/package.json')).version
|
||||||
|
const hosts = parseHostList(process.env.PEARDOCK_CLIENT_HOSTS, ALL_64)
|
||||||
|
const timeoutMs = Number(process.env.PEARDOCK_ELECTRON_DOWNLOAD_TIMEOUT_MS || 180000)
|
||||||
|
const zipDir = path.join(root, '.cache', 'electron-zips')
|
||||||
|
|
||||||
|
function withTimeout(promise, ms, label) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
reject(new Error(`[predownload-electron] timeout after ${ms}ms: ${label}`))
|
||||||
|
}, ms)
|
||||||
|
promise.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
resolve(v)
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(zipDir, { recursive: true })
|
||||||
|
if (process.env.ELECTRON_CACHE || process.env.electron_config_cache) {
|
||||||
|
console.log(
|
||||||
|
'[predownload-electron] cache:',
|
||||||
|
process.env.ELECTRON_CACHE || process.env.electron_config_cache
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[predownload-electron] electron@${electronVersion} hosts: ${hosts.join(', ')}`
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
const { platform, arch } = hostToElectron(host)
|
||||||
|
const label = `${platform}-${arch}`
|
||||||
|
const destName = `electron-v${electronVersion}-${platform}-${arch}.zip`
|
||||||
|
const destPath = path.join(zipDir, destName)
|
||||||
|
if (fs.existsSync(destPath) && fs.statSync(destPath).size > 1_000_000) {
|
||||||
|
console.log(`[predownload-electron] skip (present): ${destName}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[predownload-electron] downloading ${label}…`)
|
||||||
|
const t0 = Date.now()
|
||||||
|
const zipPath = await withTimeout(
|
||||||
|
downloadArtifact({
|
||||||
|
version: electronVersion,
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
artifactName: 'electron',
|
||||||
|
}),
|
||||||
|
timeoutMs,
|
||||||
|
label
|
||||||
|
)
|
||||||
|
// Copy into a stable electronZipDir layout for packager
|
||||||
|
fs.copyFileSync(zipPath, destPath)
|
||||||
|
const mb = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1)
|
||||||
|
console.log(
|
||||||
|
`[predownload-electron] ok ${label} → ${destName} (${mb} MB) in ${((Date.now() - t0) / 1000).toFixed(1)}s`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[predownload-electron] zip dir: ${zipDir}`)
|
||||||
|
console.log('[predownload-electron] done')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user