ci: bootstrap Linux release toolchains
This commit is contained in:
parent
30d51278c9
commit
6a9608c447
5 changed files with 192 additions and 11 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,6 +19,7 @@ release/
|
||||||
!/release/android-release-identity.json
|
!/release/android-release-identity.json
|
||||||
!/release/mobile-release-evidence-public.pem
|
!/release/mobile-release-evidence-public.pem
|
||||||
*.unpacked
|
*.unpacked
|
||||||
|
/.ci-tools/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,12 @@ test-unit:
|
||||||
api-server-tests:
|
api-server-tests:
|
||||||
stage: test
|
stage: test
|
||||||
image: mcr.microsoft.com/dotnet/sdk:10.0.302-noble
|
image: mcr.microsoft.com/dotnet/sdk:10.0.302-noble
|
||||||
before_script: []
|
before_script:
|
||||||
|
- . scripts/ci/bootstrap-linux-toolchain.sh dotnet
|
||||||
|
cache:
|
||||||
|
key: linux-dotnet-sdk-10.0.302
|
||||||
|
paths:
|
||||||
|
- .ci-tools/dotnet-sdk-10.0.302/
|
||||||
script:
|
script:
|
||||||
- dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
|
- dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
|
||||||
- dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
|
- dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
|
||||||
|
|
@ -75,7 +80,12 @@ api-server-tests:
|
||||||
edge-functions-quality:
|
edge-functions-quality:
|
||||||
stage: test
|
stage: test
|
||||||
image: denoland/deno:2.8.1
|
image: denoland/deno:2.8.1
|
||||||
before_script: []
|
before_script:
|
||||||
|
- . scripts/ci/bootstrap-linux-toolchain.sh deno
|
||||||
|
cache:
|
||||||
|
key: linux-deno-2.8.1
|
||||||
|
paths:
|
||||||
|
- .ci-tools/deno-2.8.1/
|
||||||
script:
|
script:
|
||||||
- for entrypoint in server/supabase/functions/*/index.ts; do deno check --config server/supabase/functions/deno.json "$entrypoint"; done
|
- for entrypoint in server/supabase/functions/*/index.ts; do deno check --config server/supabase/functions/deno.json "$entrypoint"; done
|
||||||
- deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
|
- deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
|
||||||
|
|
@ -104,8 +114,7 @@ mobile-android:
|
||||||
needs:
|
needs:
|
||||||
- mobile-quality
|
- mobile-quality
|
||||||
before_script:
|
before_script:
|
||||||
- export ANDROID_HOME=/opt/android
|
- . scripts/ci/bootstrap-linux-toolchain.sh android
|
||||||
- export ANDROID_SDK_ROOT=/opt/android
|
|
||||||
- curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
|
- curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
|
||||||
- echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
|
- echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
|
||||||
- mkdir -p /tmp/node24
|
- mkdir -p /tmp/node24
|
||||||
|
|
@ -114,9 +123,13 @@ mobile-android:
|
||||||
- node --version | grep -Fx "v${NODE_VERSION}"
|
- node --version | grep -Fx "v${NODE_VERSION}"
|
||||||
- export D3RO_VERSION_NAME="0.0.0-e2e.${CI_PIPELINE_IID}"
|
- export D3RO_VERSION_NAME="0.0.0-e2e.${CI_PIPELINE_IID}"
|
||||||
- export D3RO_VERSION_CODE="${CI_PIPELINE_IID}"
|
- export D3RO_VERSION_CODE="${CI_PIPELINE_IID}"
|
||||||
- sdkmanager "platforms;android-36" "build-tools;36.0.0" >/dev/null
|
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm --prefix apps/mobile-rn ci --workspaces=false
|
- npm --prefix apps/mobile-rn ci --workspaces=false
|
||||||
|
cache:
|
||||||
|
key: linux-android-15859902-jdk-17.0.20.1
|
||||||
|
paths:
|
||||||
|
- .ci-tools/android-sdk/
|
||||||
|
- .ci-tools/microsoft-jdk-17.0.20.1/
|
||||||
script:
|
script:
|
||||||
- node scripts/ci/prepare-whisper-model.mjs
|
- node scripts/ci/prepare-whisper-model.mjs
|
||||||
- cd apps/mobile-rn/android
|
- cd apps/mobile-rn/android
|
||||||
|
|
@ -151,10 +164,14 @@ mobile-emulator-e2e:
|
||||||
needs:
|
needs:
|
||||||
- job: mobile-android
|
- job: mobile-android
|
||||||
artifacts: true
|
artifacts: true
|
||||||
before_script: []
|
before_script:
|
||||||
|
- . scripts/ci/bootstrap-linux-toolchain.sh android
|
||||||
|
cache:
|
||||||
|
key: linux-android-15859902-jdk-17.0.20.1
|
||||||
|
paths:
|
||||||
|
- .ci-tools/android-sdk/
|
||||||
|
- .ci-tools/microsoft-jdk-17.0.20.1/
|
||||||
script:
|
script:
|
||||||
- export ANDROID_HOME=/opt/android
|
|
||||||
- export ANDROID_SDK_ROOT=/opt/android
|
|
||||||
- sdkmanager "platform-tools" "emulator" "platforms;android-35" "system-images;android-35;google_apis;x86_64" >/dev/null
|
- sdkmanager "platform-tools" "emulator" "platforms;android-35" "system-images;android-35;google_apis;x86_64" >/dev/null
|
||||||
- echo no | avdmanager create avd --force --name d3ro_ci_api35 --package "system-images;android-35;google_apis;x86_64"
|
- echo no | avdmanager create avd --force --name d3ro_ci_api35 --package "system-images;android-35;google_apis;x86_64"
|
||||||
- emulator -avd d3ro_ci_api35 -no-window -noaudio -no-boot-anim -gpu swiftshader_indirect -camera-back none &
|
- emulator -avd d3ro_ci_api35 -no-window -noaudio -no-boot-anim -gpu swiftshader_indirect -camera-back none &
|
||||||
|
|
@ -296,8 +313,7 @@ mobile-production-release:
|
||||||
script:
|
script:
|
||||||
- |
|
- |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export ANDROID_HOME=/opt/android
|
. scripts/ci/bootstrap-linux-toolchain.sh android
|
||||||
export ANDROID_SDK_ROOT=/opt/android
|
|
||||||
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
|
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
|
||||||
echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
|
echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
|
||||||
mkdir -p /tmp/node24
|
mkdir -p /tmp/node24
|
||||||
|
|
@ -331,7 +347,6 @@ mobile-production-release:
|
||||||
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_ID"
|
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_ID"
|
||||||
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER"
|
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER"
|
||||||
test -n "$D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID"
|
test -n "$D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID"
|
||||||
sdkmanager "platforms;android-36" "build-tools;36.0.0" >/dev/null
|
|
||||||
npm ci
|
npm ci
|
||||||
npm --prefix apps/mobile-rn ci --workspaces=false
|
npm --prefix apps/mobile-rn ci --workspaces=false
|
||||||
npm run security:secrets:test
|
npm run security:secrets:test
|
||||||
|
|
@ -414,6 +429,11 @@ mobile-production-release:
|
||||||
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \
|
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \
|
||||||
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \
|
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \
|
||||||
> apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
|
> apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
|
||||||
|
cache:
|
||||||
|
key: linux-android-15859902-jdk-17.0.20.1
|
||||||
|
paths:
|
||||||
|
- .ci-tools/android-sdk/
|
||||||
|
- .ci-tools/microsoft-jdk-17.0.20.1/
|
||||||
after_script:
|
after_script:
|
||||||
- rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem
|
- rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem
|
||||||
artifacts:
|
artifacts:
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,30 @@ vi.mock('electron', () => ({
|
||||||
decryptString: vi.fn((b: Buffer) => b.toString('utf-8')),
|
decryptString: vi.fn((b: Buffer) => b.toString('utf-8')),
|
||||||
},
|
},
|
||||||
Notification: vi.fn(),
|
Notification: vi.fn(),
|
||||||
|
globalShortcut: {
|
||||||
|
register: vi.fn(() => true),
|
||||||
|
unregister: vi.fn(),
|
||||||
|
unregisterAll: vi.fn(),
|
||||||
|
isRegistered: vi.fn(() => false),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Unit tests must not load the native global keyboard hook. Linux shell runners
|
||||||
|
// intentionally do not provide an X11 session or libXrandr, and product-level
|
||||||
|
// hook behaviour is covered by runtime/E2E validation instead.
|
||||||
|
vi.mock('uiohook-napi', () => ({
|
||||||
|
uIOhook: {
|
||||||
|
on: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
},
|
||||||
|
UiohookKey: new Proxy<Record<string, number>>({}, {
|
||||||
|
get: (_target, property) => Array.from(String(property)).reduce(
|
||||||
|
(value, character) => ((value * 31) + character.charCodeAt(0)) & 0x7fffffff,
|
||||||
|
17,
|
||||||
|
),
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// WindowManager 모킹 — VoiceModeService가 import하므로 필요
|
// WindowManager 모킹 — VoiceModeService가 import하므로 필요
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ authoritative release commit
|
||||||
- Windows installer와 `latest.yml`은 필수다. macOS 산출물이 없는 Windows-only release를 의도했다면 그 판단을 release record에 남긴다.
|
- Windows installer와 `latest.yml`은 필수다. macOS 산출물이 없는 Windows-only release를 의도했다면 그 판단을 release record에 남긴다.
|
||||||
- `package-windows`는 external public-trust code-signing PFX를 protected file variable `WIN_CSC_PFX_FILE`로, 암호와 exact certificate subject를 protected `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`로 받아야 한다. GitHub Release도 `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`가 모두 없으면 실패한다.
|
- `package-windows`는 external public-trust code-signing PFX를 protected file variable `WIN_CSC_PFX_FILE`로, 암호와 exact certificate subject를 protected `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`로 받아야 한다. GitHub Release도 `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`가 모두 없으면 실패한다.
|
||||||
- `scripts/ci/verify-windows-release-artifact.ps1`는 installer와 unpacked app의 Authenticode `Valid`, exact signer subject, non-self-signed code-signing EKU, PE version, `latest.yml` path/size/SHA-512를 검증한다. 로컬 self-signed `Everything2EverythingDev`는 production 신뢰 인증서가 아니며 gate에서 명시적으로 거부한다.
|
- `scripts/ci/verify-windows-release-artifact.ps1`는 installer와 unpacked app의 Authenticode `Valid`, exact signer subject, non-self-signed code-signing EKU, PE version, `latest.yml` path/size/SHA-512를 검증한다. 로컬 self-signed `Everything2EverythingDev`는 production 신뢰 인증서가 아니며 gate에서 명시적으로 거부한다.
|
||||||
|
- 현재 GitLab Linux runner는 Shell executor이므로 job의 `image:`를 툴체인 근거로 간주하지 않는다. `.gitlab-ci.yml`은 `scripts/ci/bootstrap-linux-toolchain.sh`를 source해 .NET 10.0.302, Deno 2.8.1, Microsoft OpenJDK 17.0.20.1, Android command-line tools 15859902를 고정 해시로 검증·캐시한 뒤 실행한다.
|
||||||
|
|
||||||
## 자동 업데이트 계약
|
## 자동 업데이트 계약
|
||||||
|
|
||||||
|
|
|
||||||
135
scripts/ci/bootstrap-linux-toolchain.sh
Normal file
135
scripts/ci/bootstrap-linux-toolchain.sh
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# GitLab's current Linux runner uses the Shell executor, so job-level `image:`
|
||||||
|
# declarations do not provide their advertised SDKs. Source this file from a
|
||||||
|
# job to install a pinned, checksum-verified toolchain inside CI_PROJECT_DIR.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mode="${1:-}"
|
||||||
|
if [[ -z "$mode" ]]; then
|
||||||
|
echo 'bootstrap-linux-toolchain: mode is required (dotnet|deno|android)' >&2
|
||||||
|
return 64 2>/dev/null || exit 64
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${CI_PROJECT_DIR:?CI_PROJECT_DIR is required}"
|
||||||
|
tools_root="$CI_PROJECT_DIR/.ci-tools"
|
||||||
|
mkdir -p "$tools_root"
|
||||||
|
|
||||||
|
download_verified() {
|
||||||
|
local url="$1"
|
||||||
|
local expected_checksum="$2"
|
||||||
|
local destination="$3"
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --location "$url" --output "$destination"
|
||||||
|
case "${#expected_checksum}" in
|
||||||
|
64) printf '%s %s\n' "$expected_checksum" "$destination" | sha256sum --check --status ;;
|
||||||
|
128) printf '%s %s\n' "$expected_checksum" "$destination" | sha512sum --check --status ;;
|
||||||
|
*)
|
||||||
|
echo 'bootstrap-linux-toolchain: unsupported checksum length' >&2
|
||||||
|
return 65
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap_dotnet() {
|
||||||
|
export DOTNET_ROOT="$tools_root/dotnet-sdk-10.0.302"
|
||||||
|
if [[ ! -x "$DOTNET_ROOT/dotnet" ]]; then
|
||||||
|
local archive
|
||||||
|
archive="$(mktemp "${TMPDIR:-/tmp}/d3ro-dotnet.XXXXXX.tar.gz")"
|
||||||
|
download_verified \
|
||||||
|
'https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.302/dotnet-sdk-10.0.302-linux-x64.tar.gz' \
|
||||||
|
'10069bec8783596484a610332f090d562802a41b9b40e3327a5a5688b572e10c296ae300f940d40461f23c157ed1b0843c2f8e6b3f20d8d8d9d83432d8143bac' \
|
||||||
|
"$archive"
|
||||||
|
mkdir -p "$DOTNET_ROOT"
|
||||||
|
tar --extract --gzip --file "$archive" --directory "$DOTNET_ROOT"
|
||||||
|
rm -f "$archive"
|
||||||
|
fi
|
||||||
|
export DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
|
export DOTNET_NOLOGO=1
|
||||||
|
export PATH="$DOTNET_ROOT:$PATH"
|
||||||
|
[[ "$(dotnet --version)" == '10.0.302' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap_deno() {
|
||||||
|
local deno_root="$tools_root/deno-2.8.1"
|
||||||
|
if [[ ! -x "$deno_root/deno" ]]; then
|
||||||
|
local archive
|
||||||
|
archive="$(mktemp "${TMPDIR:-/tmp}/d3ro-deno.XXXXXX.zip")"
|
||||||
|
download_verified \
|
||||||
|
'https://github.com/denoland/deno/releases/download/v2.8.1/deno-x86_64-unknown-linux-gnu.zip' \
|
||||||
|
'2d7bb6195226ac832e0bf7109a115f0af65ee69ac797a4bbde5b27a06cc242d9' \
|
||||||
|
"$archive"
|
||||||
|
mkdir -p "$deno_root"
|
||||||
|
unzip -q "$archive" -d "$deno_root"
|
||||||
|
rm -f "$archive"
|
||||||
|
fi
|
||||||
|
export PATH="$deno_root:$PATH"
|
||||||
|
local deno_version
|
||||||
|
deno_version="$(deno --version)"
|
||||||
|
[[ "$deno_version" == deno\ 2.8.1* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap_android() {
|
||||||
|
export JAVA_HOME="$tools_root/microsoft-jdk-17.0.20.1"
|
||||||
|
if [[ ! -x "$JAVA_HOME/bin/java" ]]; then
|
||||||
|
local jdk_archive
|
||||||
|
jdk_archive="$(mktemp "${TMPDIR:-/tmp}/d3ro-jdk.XXXXXX.tar.gz")"
|
||||||
|
download_verified \
|
||||||
|
'https://download.visualstudio.microsoft.com/download/pr/a9672056-0a4d-425d-8488-f481ee56319c/5897054114dd5c0acab8489cd815dad9/microsoft-jdk-17.0.20.1-linux-x64.tar.gz' \
|
||||||
|
'd00e5b04e9726b63d915706c7049e5297c9f40239ce8a12fcc68b7267fa91ad2' \
|
||||||
|
"$jdk_archive"
|
||||||
|
mkdir -p "$JAVA_HOME"
|
||||||
|
tar --extract --gzip --file "$jdk_archive" --directory "$JAVA_HOME" --strip-components=1
|
||||||
|
rm -f "$jdk_archive"
|
||||||
|
fi
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
local java_version
|
||||||
|
java_version="$(java -version 2>&1)"
|
||||||
|
[[ "$java_version" == *'17.0.20.1'* ]]
|
||||||
|
|
||||||
|
export ANDROID_HOME="$tools_root/android-sdk"
|
||||||
|
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||||
|
local sdkmanager_path="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
if [[ ! -x "$sdkmanager_path" ]]; then
|
||||||
|
local cli_archive cli_stage
|
||||||
|
cli_archive="$(mktemp "${TMPDIR:-/tmp}/d3ro-android-cli.XXXXXX.zip")"
|
||||||
|
cli_stage="$(mktemp -d "${TMPDIR:-/tmp}/d3ro-android-cli.XXXXXX")"
|
||||||
|
download_verified \
|
||||||
|
'https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip' \
|
||||||
|
'4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583' \
|
||||||
|
"$cli_archive"
|
||||||
|
unzip -q "$cli_archive" -d "$cli_stage"
|
||||||
|
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||||
|
mv "$cli_stage/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
|
rmdir "$cli_stage"
|
||||||
|
rm -f "$cli_archive"
|
||||||
|
fi
|
||||||
|
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||||
|
sdkmanager --version >/dev/null
|
||||||
|
|
||||||
|
if [[ ! -f "$ANDROID_HOME/platforms/android-36/android.jar" \
|
||||||
|
|| ! -x "$ANDROID_HOME/build-tools/36.0.0/aapt2" \
|
||||||
|
|| ! -x "$ANDROID_HOME/platform-tools/adb" ]]; then
|
||||||
|
set +o pipefail
|
||||||
|
yes | sdkmanager --licenses >/dev/null
|
||||||
|
local license_status=$?
|
||||||
|
set -o pipefail
|
||||||
|
[[ "$license_status" -eq 0 ]]
|
||||||
|
sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0' >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f "$ANDROID_HOME/platforms/android-36/android.jar" ]]
|
||||||
|
[[ -x "$ANDROID_HOME/build-tools/36.0.0/aapt2" ]]
|
||||||
|
[[ -x "$ANDROID_HOME/platform-tools/adb" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
dotnet) bootstrap_dotnet ;;
|
||||||
|
deno) bootstrap_deno ;;
|
||||||
|
android) bootstrap_android ;;
|
||||||
|
*)
|
||||||
|
echo "bootstrap-linux-toolchain: unsupported mode: $mode" >&2
|
||||||
|
return 64 2>/dev/null || exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Loading…
Add table
Add a link
Reference in a new issue