mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
1215a45f93
Register Czech, Danish, Finnish, Hungarian, Korean, Russian, Simplified Chinese, Swedish, and
Ukrainian so the language picker, browser detection, and server preference validation accept them.
Catalogs hold English fallback values; Crowdin supplies translations through the normal export flow.
Give each catalog exactly the CLDR plural categories its locale defines rather than copying the
English branches. Czech, Russian, and Ukrainian gain `few` and `many` seeded from `other` because
the source has no matching text. Korean and Chinese drop `one`, which they have no rule for and
Crowdin cannot store. Those synthesized branches all start from the same English string, so machine
translation returns identical forms where the grammar differs and a human has to resolve them.
Ship Chinese as Simplified only under the bare `zh` locale so every zh-* browser resolves to it.
Traditional Chinese, also requested in #86, would arrive later as a separate zh-Hant catalog.
Use `sv-SE` for Swedish in the Crowdin config, because Crowdin has no bare `sv` language and would
have skipped the export. Tolerate a wrapped SUPPORTED_LOCALES array in validate-locales, which
prettier now produces since the list outgrew the print width, and extend the exhaustive ICU test to
reach Polish `other`, a category its counts never selected.
Add the repository activity embed to the README alongside the existing translation progress chart.
Repair the KOReader plugin source assertions that 40e366a4 left stale after reshaping the
dashboard streak block and the downloaded badge icon lookup, and watch `koreader-plugin/**` in
the server CI path filter so plugin-only changes run the server suite that reads those files.
Closes #86
Closes #363
Closes #576
766 lines
29 KiB
YAML
766 lines
29 KiB
YAML
name: CI
|
|
run-name: >-
|
|
${{ (github.event_name == 'push' && format('CI - {0} ({1})', github.event.head_commit.message, github.ref_name)) || (github.event_name == 'pull_request' && format('CI - PR #{0}: {1}', github.event.pull_request.number, github.event.pull_request.title)) || (github.event_name == 'schedule' && 'CI - nightly') || 'CI - manual' }}
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
schedule:
|
|
- cron: "17 4 * * *"
|
|
pull_request:
|
|
branches: [main]
|
|
push:
|
|
branches: [main]
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
NODE_VERSION: "24"
|
|
PNPM_VERSION: "10"
|
|
REGISTRY: ghcr.io
|
|
IMAGE_NAME: ${{ github.repository }}
|
|
|
|
jobs:
|
|
dependency-review:
|
|
# Enable only when repository security analysis supports dependency review.
|
|
if: github.event_name == 'pull_request' && vars.ENABLE_DEPENDENCY_REVIEW == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
continue-on-error: true
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Dependency review
|
|
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
|
|
|
pr-conventions:
|
|
name: PR Conventions
|
|
if: github.event_name == 'pull_request'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
issues: read
|
|
pull-requests: read
|
|
steps:
|
|
- name: Checkout trusted pull request base
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
ref: ${{ github.event.pull_request.base.sha }}
|
|
|
|
- name: Classify Crowdin pull request
|
|
id: crowdin
|
|
shell: bash
|
|
env:
|
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
BASE_REPO: ${{ github.repository }}
|
|
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
|
HEAD_BRANCH: ${{ github.head_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
args=("$PR_BASE_SHA" "$PR_HEAD_SHA" "$BASE_REPO" "$HEAD_REPO" "$HEAD_BRANCH")
|
|
if git cat-file -e "$PR_BASE_SHA:scripts/classify-crowdin-pr.sh" 2>/dev/null; then
|
|
is_crowdin="$(git show "$PR_BASE_SHA:scripts/classify-crowdin-pr.sh" | bash -s -- "${args[@]}")"
|
|
else
|
|
is_crowdin="$(git show "$PR_HEAD_SHA:scripts/classify-crowdin-pr.sh" | bash -s -- "${args[@]}")"
|
|
fi
|
|
echo "is_crowdin=$is_crowdin" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Validate PR branch name
|
|
if: github.event.pull_request.user.login != 'dependabot[bot]' && steps.crowdin.outputs.is_crowdin != 'true'
|
|
shell: bash
|
|
env:
|
|
PR_BRANCH: ${{ github.head_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
pattern='^BO-[0-9]+-[a-z0-9]+(-[a-z0-9]+)*$'
|
|
if [[ ! "$PR_BRANCH" =~ $pattern ]]; then
|
|
echo "::error::Branch name '$PR_BRANCH' is invalid."
|
|
echo "::error::Use BO-<issue-number>-<short-description> (example: BO-123-fix-reader-sync)."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Validate referenced issues exist
|
|
if: github.event.pull_request.user.login != 'dependabot[bot]' && steps.crowdin.outputs.is_crowdin != 'true'
|
|
shell: bash
|
|
env:
|
|
GH_REPO: ${{ github.repository }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
PR_BRANCH: ${{ github.head_ref }}
|
|
PR_BODY: ${{ github.event.pull_request.body }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
validate_issue_exists() {
|
|
local issue_repo="$1"
|
|
local issue_number="$2"
|
|
local reference_context="$3"
|
|
local response_file
|
|
local http_code
|
|
local api_url
|
|
local state
|
|
local reference_label
|
|
|
|
response_file="$(mktemp)"
|
|
api_url="https://api.github.com/repos/${issue_repo}/issues/${issue_number}"
|
|
http_code="$(curl -sS -o "${response_file}" -w "%{http_code}" \
|
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"${api_url}")"
|
|
|
|
if [[ "${issue_repo}" == "${GH_REPO}" ]]; then
|
|
reference_label="#${issue_number}"
|
|
else
|
|
reference_label="${issue_repo}#${issue_number}"
|
|
fi
|
|
|
|
if [[ "${http_code}" != "200" ]]; then
|
|
echo "::error::${reference_context} references ${reference_label}, but it does not exist or is not accessible."
|
|
rm -f "${response_file}"
|
|
return 1
|
|
fi
|
|
|
|
if jq -e '.pull_request != null' "${response_file}" > /dev/null; then
|
|
echo "::error::${reference_context} references ${reference_label}, but that number is a pull request, not an issue."
|
|
rm -f "${response_file}"
|
|
return 1
|
|
fi
|
|
|
|
state="$(jq -r '.state // "unknown"' "${response_file}")"
|
|
echo "${reference_context}: issue ${reference_label} exists (state=${state})."
|
|
rm -f "${response_file}"
|
|
}
|
|
|
|
if [[ "${PR_BRANCH}" =~ ^BO-([0-9]+)- ]]; then
|
|
branch_issue_number="${BASH_REMATCH[1]}"
|
|
else
|
|
echo "::error::Unable to extract issue number from branch '${PR_BRANCH}'."
|
|
exit 1
|
|
fi
|
|
|
|
validate_issue_exists "${GH_REPO}" "${branch_issue_number}" "Branch name"
|
|
|
|
closing_keyword_pattern='(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)'
|
|
issue_reference_pattern="(^|[^[:alnum:]_])${closing_keyword_pattern}([[:space:]]*:[[:space:]]*|[[:space:]]+)([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#[1-9][0-9]*"
|
|
issue_matches="$(printf '%s\n' "${PR_BODY}" | grep -Eio "${issue_reference_pattern}" || true)"
|
|
if [[ -z "${issue_matches}" ]]; then
|
|
echo "::error::PR description must include at least one GitHub closing-keyword issue reference (for example: 'Closes #123', 'Fixes: #123', or 'RESOLVED owner/repo#123')."
|
|
exit 1
|
|
fi
|
|
|
|
issue_refs="$(printf '%s\n' "${issue_matches}" \
|
|
| tr '[:upper:]' '[:lower:]' \
|
|
| sed -E 's/^[^a-z0-9_]*//' \
|
|
| sed -E "s/^${closing_keyword_pattern}([[:space:]]*:[[:space:]]*|[[:space:]]+)//" \
|
|
| sort -u)"
|
|
while IFS= read -r issue_ref; do
|
|
[[ -z "${issue_ref}" ]] && continue
|
|
|
|
if [[ "${issue_ref}" =~ ^([a-z0-9_.-]+/[a-z0-9_.-]+)#([1-9][0-9]*)$ ]]; then
|
|
issue_repo="${BASH_REMATCH[1]}"
|
|
issue_number="${BASH_REMATCH[2]}"
|
|
elif [[ "${issue_ref}" =~ ^#([1-9][0-9]*)$ ]]; then
|
|
issue_repo="${GH_REPO}"
|
|
issue_number="${BASH_REMATCH[1]}"
|
|
else
|
|
echo "::error::Unable to parse issue reference '${issue_ref}' in PR description."
|
|
exit 1
|
|
fi
|
|
|
|
validate_issue_exists "${issue_repo}" "${issue_number}" "PR description"
|
|
done <<< "${issue_refs}"
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Set up Node.js and pnpm
|
|
uses: ./.github/actions/setup-node-pnpm
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
pnpm-version: ${{ env.PNPM_VERSION }}
|
|
|
|
- name: Lint PR title
|
|
shell: bash
|
|
env:
|
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
run: printf '%s\n' "$PR_TITLE" | pnpm exec commitlint --config commitlint.config.cjs
|
|
|
|
- name: Lint PR commit headers
|
|
shell: bash
|
|
env:
|
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
pnpm exec commitlint --config commitlint.config.cjs --from "$PR_BASE_SHA" --to "$PR_HEAD_SHA" --verbose
|
|
|
|
detect-changes:
|
|
name: Detect Changed Paths
|
|
runs-on: ubuntu-latest
|
|
needs: [dependency-review, pr-conventions]
|
|
timeout-minutes: 10
|
|
if: >
|
|
always() &&
|
|
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
|
|
(needs.pr-conventions.result == 'success' || needs.pr-conventions.result == 'skipped')
|
|
outputs:
|
|
server: ${{ steps.manual.outputs.server || steps.changes.outputs.server }}
|
|
client: ${{ steps.manual.outputs.client || steps.changes.outputs.client }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Manual and nightly runs include all scopes
|
|
id: manual
|
|
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
|
shell: bash
|
|
run: |
|
|
echo "server=true" >> "$GITHUB_OUTPUT"
|
|
echo "client=true" >> "$GITHUB_OUTPUT"
|
|
|
|
# For push events dorny/paths-filter diffs github.event.before..github.sha.
|
|
# New-branch pushes (before = 0000...) are handled by treating all files as changed.
|
|
# Force-push edge cases (before SHA no longer reachable) may mark all files changed too.
|
|
- name: Compute changed scopes
|
|
id: changes
|
|
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
|
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4
|
|
with:
|
|
filters: |
|
|
server:
|
|
- 'server/**'
|
|
# Server tests assert against the plugin sources, so plugin-only
|
|
# changes can break them.
|
|
- 'koreader-plugin/**'
|
|
- 'packages/**'
|
|
- 'package.json'
|
|
- 'pnpm-lock.yaml'
|
|
- 'pnpm-workspace.yaml'
|
|
- '.github/actions/setup-node-pnpm/action.yml'
|
|
- '.github/workflows/ci.yml'
|
|
client:
|
|
- 'client/**'
|
|
- 'packages/**'
|
|
- 'package.json'
|
|
- 'pnpm-lock.yaml'
|
|
- 'pnpm-workspace.yaml'
|
|
- '.github/actions/setup-node-pnpm/action.yml'
|
|
- '.github/workflows/ci.yml'
|
|
|
|
- name: Print change scope
|
|
shell: bash
|
|
run: |
|
|
echo "server=${{ steps.manual.outputs.server || steps.changes.outputs.server }}"
|
|
echo "client=${{ steps.manual.outputs.client || steps.changes.outputs.client }}"
|
|
|
|
lint:
|
|
name: Lint (${{ matrix.package_label }})
|
|
runs-on: ubuntu-latest
|
|
needs: [dependency-review, detect-changes]
|
|
timeout-minutes: 10
|
|
if: >
|
|
always() &&
|
|
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
|
|
needs.detect-changes.result == 'success'
|
|
strategy:
|
|
fail-fast: ${{ github.event_name == 'pull_request' }}
|
|
matrix:
|
|
package: [server, client]
|
|
include:
|
|
- package: server
|
|
package_label: Server
|
|
- package: client
|
|
package_label: Client
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Check for package changes
|
|
id: has-changes
|
|
shell: bash
|
|
env:
|
|
PACKAGE: ${{ matrix.package }}
|
|
SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }}
|
|
CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }}
|
|
run: |
|
|
if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \
|
|
[[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then
|
|
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "run=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Checkout
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Set up Node.js and pnpm
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
uses: ./.github/actions/setup-node-pnpm
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
pnpm-version: ${{ env.PNPM_VERSION }}
|
|
prefetch: "true"
|
|
|
|
- name: Run lint checks
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
shell: bash
|
|
env:
|
|
PACKAGE: ${{ matrix.package }}
|
|
run: |
|
|
if [[ "$PACKAGE" == "server" ]]; then
|
|
pnpm --filter server lint:check
|
|
else
|
|
pnpm --filter client lint:check
|
|
fi
|
|
|
|
- name: Run format checks
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
shell: bash
|
|
env:
|
|
PACKAGE: ${{ matrix.package }}
|
|
run: |
|
|
if [[ "$PACKAGE" == "server" ]]; then
|
|
pnpm --filter server format:check
|
|
else
|
|
pnpm --filter client format:check
|
|
fi
|
|
|
|
typecheck:
|
|
name: Type Check (${{ matrix.package_label }})
|
|
runs-on: ubuntu-latest
|
|
needs: [dependency-review, detect-changes]
|
|
timeout-minutes: 10
|
|
if: >
|
|
always() &&
|
|
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
|
|
needs.detect-changes.result == 'success'
|
|
strategy:
|
|
fail-fast: ${{ github.event_name == 'pull_request' }}
|
|
matrix:
|
|
package: [server, client]
|
|
include:
|
|
- package: server
|
|
package_label: Server
|
|
- package: client
|
|
package_label: Client
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Check for package changes
|
|
id: has-changes
|
|
shell: bash
|
|
env:
|
|
PACKAGE: ${{ matrix.package }}
|
|
SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }}
|
|
CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }}
|
|
run: |
|
|
if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \
|
|
[[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then
|
|
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "run=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Checkout
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Set up Node.js and pnpm
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
uses: ./.github/actions/setup-node-pnpm
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
pnpm-version: ${{ env.PNPM_VERSION }}
|
|
prefetch: "true"
|
|
|
|
- name: Run typecheck
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
shell: bash
|
|
env:
|
|
PACKAGE: ${{ matrix.package }}
|
|
run: |
|
|
if [[ "$PACKAGE" == "server" ]]; then
|
|
pnpm --filter server type-check
|
|
else
|
|
pnpm --filter @bookorbit/types build
|
|
pnpm --filter client type-check
|
|
fi
|
|
|
|
tests:
|
|
name: Tests (${{ matrix.package_label }})
|
|
runs-on: ubuntu-latest
|
|
needs: [dependency-review, detect-changes]
|
|
timeout-minutes: 20
|
|
if: >
|
|
always() &&
|
|
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
|
|
needs.detect-changes.result == 'success'
|
|
strategy:
|
|
fail-fast: ${{ github.event_name == 'pull_request' }}
|
|
matrix:
|
|
package: [server, client]
|
|
include:
|
|
- package: server
|
|
package_label: Server
|
|
- package: client
|
|
package_label: Client
|
|
permissions:
|
|
contents: read
|
|
checks: write
|
|
env:
|
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
steps:
|
|
- name: Check for package changes
|
|
id: has-changes
|
|
shell: bash
|
|
env:
|
|
PACKAGE: ${{ matrix.package }}
|
|
SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }}
|
|
CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }}
|
|
run: |
|
|
if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \
|
|
[[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then
|
|
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "run=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Checkout
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Set up Node.js and pnpm
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
uses: ./.github/actions/setup-node-pnpm
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
pnpm-version: ${{ env.PNPM_VERSION }}
|
|
prefetch: "true"
|
|
|
|
- name: Prepare test report directory
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
shell: bash
|
|
run: mkdir -p "$GITHUB_WORKSPACE/test-results/${{ matrix.package }}"
|
|
|
|
- name: Run tests
|
|
if: steps.has-changes.outputs.run == 'true'
|
|
shell: bash
|
|
env:
|
|
PACKAGE: ${{ matrix.package }}
|
|
JUNIT_OUTPUT: ${{ github.workspace }}/test-results/${{ matrix.package }}/junit.xml
|
|
run: |
|
|
if [[ "$PACKAGE" == "server" ]]; then
|
|
pnpm --filter server exec vitest --run --coverage --reporter=default --reporter=github-actions --reporter=junit --outputFile.junit="$JUNIT_OUTPUT"
|
|
else
|
|
pnpm --filter client exec vitest --run --coverage --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT"
|
|
fi
|
|
|
|
- name: Upload coverage reports to Codecov
|
|
if: steps.has-changes.outputs.run == 'true' && env.CODECOV_TOKEN != ''
|
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
|
with:
|
|
token: ${{ env.CODECOV_TOKEN }}
|
|
files: ${{ matrix.package == 'server' && 'server/coverage/lcov.info' || 'client/coverage/lcov.info' }}
|
|
flags: ${{ matrix.package }}
|
|
name: ${{ matrix.package }}-coverage
|
|
verbose: true
|
|
|
|
- name: Upload coverage reports to Codecov for fork PR
|
|
if: >
|
|
steps.has-changes.outputs.run == 'true' &&
|
|
env.CODECOV_TOKEN == '' &&
|
|
github.event_name == 'pull_request' &&
|
|
github.event.pull_request.head.repo.full_name != github.repository &&
|
|
github.event.repository.private == false
|
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
|
with:
|
|
files: ${{ matrix.package == 'server' && 'server/coverage/lcov.info' || 'client/coverage/lcov.info' }}
|
|
flags: ${{ matrix.package }}
|
|
name: ${{ matrix.package }}-coverage
|
|
verbose: true
|
|
|
|
- name: Publish test report annotations
|
|
if: >
|
|
always() &&
|
|
steps.has-changes.outputs.run == 'true' &&
|
|
hashFiles(format('test-results/{0}/junit.xml', matrix.package)) != '' &&
|
|
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
|
uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2
|
|
with:
|
|
check_name: ${{ matrix.package == 'server' && 'Server Unit Tests' || 'Client Unit Tests' }}
|
|
files: ${{ format('test-results/{0}/junit.xml', matrix.package) }}
|
|
fail_on: nothing
|
|
comment_mode: off
|
|
|
|
- name: Upload test report artifact
|
|
if: >
|
|
always() &&
|
|
steps.has-changes.outputs.run == 'true' &&
|
|
hashFiles(format('test-results/{0}/junit.xml', matrix.package)) != '' &&
|
|
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: test-report-${{ matrix.package }}
|
|
path: test-results/${{ matrix.package }}
|
|
retention-days: 7
|
|
|
|
quality-gate:
|
|
name: Quality Gate
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- dependency-review
|
|
- pr-conventions
|
|
- detect-changes
|
|
- lint
|
|
- typecheck
|
|
- tests
|
|
timeout-minutes: 5
|
|
if: always()
|
|
steps:
|
|
- name: Fail if any quality job failed
|
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
|
run: |
|
|
echo "One or more quality jobs failed."
|
|
exit 1
|
|
|
|
- name: Quality checks complete
|
|
run: |
|
|
echo "dependency-review=${{ needs.dependency-review.result }}"
|
|
echo "pr-conventions=${{ needs.pr-conventions.result }}"
|
|
echo "detect-changes=${{ needs.detect-changes.result }}"
|
|
echo "lint=${{ needs.lint.result }}"
|
|
echo "typecheck=${{ needs.typecheck.result }}"
|
|
echo "tests=${{ needs.tests.result }}"
|
|
|
|
e2e-setup:
|
|
name: Detect E2E Suites
|
|
runs-on: ubuntu-latest
|
|
needs: [quality-gate]
|
|
if: always() && needs.quality-gate.result == 'success' && github.event_name == 'schedule'
|
|
timeout-minutes: 10
|
|
outputs:
|
|
matrix: ${{ steps.build-matrix.outputs.matrix }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
|
|
- name: Build suite matrix
|
|
id: build-matrix
|
|
shell: bash
|
|
env:
|
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
|
GITHUB_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
|
GITHUB_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
|
run: |
|
|
MATRIX=$(node scripts/e2e/select-matrix.mjs)
|
|
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT"
|
|
echo "selected_suites=$(echo "$MATRIX" | jq -c .)"
|
|
|
|
e2e:
|
|
needs: [e2e-setup]
|
|
if: github.event_name == 'schedule' && needs.e2e-setup.outputs.matrix != '[]'
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
suite: ${{ fromJSON(needs.e2e-setup.outputs.matrix) }}
|
|
permissions:
|
|
contents: read
|
|
checks: write
|
|
uses: ./.github/workflows/e2e-runner.yml
|
|
with:
|
|
suite-id: ${{ matrix.suite.id }}
|
|
check-name: "E2E - ${{ matrix.suite.name }}"
|
|
artifact-name: "e2e-${{ matrix.suite.id }}-report"
|
|
job-timeout-minutes: ${{ matrix.suite.timeout }}
|
|
|
|
ci-gate:
|
|
name: CI Gate
|
|
runs-on: ubuntu-latest
|
|
needs: [quality-gate, e2e-setup, e2e]
|
|
timeout-minutes: 5
|
|
if: always()
|
|
steps:
|
|
- name: Fail if any job failed or was cancelled
|
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
|
run: |
|
|
echo "One or more CI jobs failed or were cancelled."
|
|
exit 1
|
|
|
|
- name: CI complete
|
|
run: |
|
|
echo "quality-gate=${{ needs.quality-gate.result }}"
|
|
echo "e2e-setup=${{ needs.e2e-setup.result }}"
|
|
echo "e2e=${{ needs.e2e.result }}"
|
|
|
|
pr-image-scan:
|
|
name: PR Image Scan
|
|
runs-on: ubuntu-latest
|
|
needs: [ci-gate]
|
|
timeout-minutes: 45
|
|
if: >
|
|
always() &&
|
|
github.event_name == 'pull_request' &&
|
|
needs.ci-gate.result == 'success'
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Prepare PR image tag
|
|
id: pr-meta
|
|
shell: bash
|
|
env:
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
short_sha="${HEAD_SHA:0:12}"
|
|
scan_tag="pr-scan-${PR_NUMBER}-${short_sha}"
|
|
echo "scan_tag=${scan_tag}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
|
|
|
- name: Build image
|
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
|
with:
|
|
context: .
|
|
file: ./Dockerfile
|
|
push: false
|
|
load: true
|
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.pr-meta.outputs.scan_tag }}
|
|
platforms: linux/amd64
|
|
cache-from: type=gha,scope=ci-yml-pr-image
|
|
cache-to: type=gha,mode=max,scope=ci-yml-pr-image
|
|
|
|
- name: Scan image with Trivy
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.pr-meta.outputs.scan_tag }}
|
|
format: table
|
|
vuln-type: os,library
|
|
severity: CRITICAL,HIGH
|
|
ignore-unfixed: true
|
|
exit-code: "1"
|
|
|
|
pr-image-publish:
|
|
name: PR Image Publish
|
|
runs-on: ubuntu-latest
|
|
needs: [ci-gate, pr-image-scan]
|
|
timeout-minutes: 45
|
|
if: >
|
|
always() &&
|
|
github.event_name == 'pull_request' &&
|
|
needs.ci-gate.result == 'success' &&
|
|
needs.pr-image-scan.result == 'success' &&
|
|
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Prepare PR publish tag
|
|
id: pr-meta
|
|
shell: bash
|
|
env:
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
short_sha="${HEAD_SHA:0:12}"
|
|
publish_tag="pr-${PR_NUMBER}-${short_sha}"
|
|
echo "publish_tag=${publish_tag}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GHCR_TOKEN != '' && secrets.GHCR_TOKEN || github.token }}
|
|
|
|
- name: Build and push PR image
|
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
|
with:
|
|
context: .
|
|
file: ./Dockerfile
|
|
push: true
|
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.pr-meta.outputs.publish_tag }}
|
|
platforms: linux/amd64
|
|
cache-from: type=gha,scope=ci-yml-pr-image
|
|
cache-to: type=gha,mode=max,scope=ci-yml-pr-image
|
|
|
|
ci-performance:
|
|
name: CI Performance
|
|
runs-on: ubuntu-latest
|
|
needs: [ci-gate]
|
|
if: always()
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
steps:
|
|
- name: Compare duration with previous successful run
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
GH_REPO: ${{ github.repository }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
CURRENT_JSON=$(gh api "repos/${GH_REPO}/actions/runs/${RUN_ID}")
|
|
CURRENT_STARTED=$(echo "$CURRENT_JSON" | jq -r '.run_started_at // .created_at')
|
|
CURRENT_UPDATED=$(echo "$CURRENT_JSON" | jq -r '.updated_at')
|
|
CURRENT_DURATION=$(node -e 'const [start, end] = process.argv.slice(1); console.log(Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 1000)));' "$CURRENT_STARTED" "$CURRENT_UPDATED")
|
|
|
|
PREVIOUS_JSON=$(gh api "repos/${GH_REPO}/actions/workflows/ci.yml/runs?per_page=30&event=${EVENT_NAME}&status=completed")
|
|
PREVIOUS_STARTED=$(echo "$PREVIOUS_JSON" | jq -r --arg run_id "${RUN_ID}" '.workflow_runs[] | select((.id | tostring) != $run_id and .conclusion == "success") | (.run_started_at // .created_at)' | head -n1)
|
|
PREVIOUS_UPDATED=$(echo "$PREVIOUS_JSON" | jq -r --arg run_id "${RUN_ID}" '.workflow_runs[] | select((.id | tostring) != $run_id and .conclusion == "success") | .updated_at' | head -n1)
|
|
|
|
{
|
|
echo "## CI Runtime"
|
|
echo ""
|
|
echo "- Event: \`${EVENT_NAME}\`"
|
|
echo "- Current run duration: \`${CURRENT_DURATION}s\`"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
if [[ -z "$PREVIOUS_STARTED" || -z "$PREVIOUS_UPDATED" ]]; then
|
|
echo "- Baseline: unavailable (no previous successful run for this event)" >> "$GITHUB_STEP_SUMMARY"
|
|
exit 0
|
|
fi
|
|
|
|
PREVIOUS_DURATION=$(node -e 'const [start, end] = process.argv.slice(1); console.log(Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 1000)));' "$PREVIOUS_STARTED" "$PREVIOUS_UPDATED")
|
|
RATIO=$(node -e 'const [current, previous] = process.argv.slice(1).map(Number); if (previous === 0) { console.log("N/A"); process.exit(0); } console.log((current / previous).toFixed(2));' "$CURRENT_DURATION" "$PREVIOUS_DURATION")
|
|
echo "- Previous successful run: \`${PREVIOUS_DURATION}s\`" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- Ratio (current/previous): \`${RATIO}\`" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
if [[ "$RATIO" != "N/A" ]] && node -e 'process.exit(Number(process.argv[1]) > 1.2 ? 0 : 1)' "$RATIO"; then
|
|
echo "::warning::CI duration regression detected: current=${CURRENT_DURATION}s previous=${PREVIOUS_DURATION}s ratio=${RATIO}"
|
|
fi
|