feat: enhance build process and add readiness checks for components
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Failing after 13m44s
docker-images / build-and-push (admin) (push) Successful in 1m13s
docker-images / build-and-push (backend) (push) Successful in 45m36s
docker-images / build-and-push (frontend) (push) Successful in 1m29s
docker-images / submit-indexnow (push) Successful in 18s

This commit is contained in:
2026-04-02 14:57:01 +08:00
parent 3628a46ed1
commit ebfb9c7838
6 changed files with 137 additions and 40 deletions

View File

@@ -21,13 +21,16 @@ jobs:
resolve-build-targets: resolve-build-targets:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.targets.outputs.matrix }}
count: ${{ steps.targets.outputs.count }} count: ${{ steps.targets.outputs.count }}
backend_changed: ${{ steps.targets.outputs.backend_changed }}
admin_changed: ${{ steps.targets.outputs.admin_changed }}
frontend_changed: ${{ steps.targets.outputs.frontend_changed }} frontend_changed: ${{ steps.targets.outputs.frontend_changed }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve build targets - name: Resolve build targets
id: targets id: targets
@@ -91,40 +94,32 @@ jobs:
[ -n "${SELECTED[admin]:-}" ] && COMPONENTS+=(admin) [ -n "${SELECTED[admin]:-}" ] && COMPONENTS+=(admin)
COMPONENTS_CSV="$(IFS=,; echo "${COMPONENTS[*]}")" COMPONENTS_CSV="$(IFS=,; echo "${COMPONENTS[*]}")"
export COMPONENTS_CSV BACKEND_CHANGED=false
FRONTEND_CHANGED=false
ADMIN_CHANGED=false
COUNT=0
python <<'PY' >> "$GITHUB_OUTPUT" if [ -n "${SELECTED[backend]:-}" ]; then
import json BACKEND_CHANGED=true
import os COUNT=$((COUNT + 1))
fi
mapping = { if [ -n "${SELECTED[frontend]:-}" ]; then
"backend": { FRONTEND_CHANGED=true
"component": "backend", COUNT=$((COUNT + 1))
"dockerfile": "backend/Dockerfile", fi
"context": "backend",
"default_image_name": "termi-astro-backend",
},
"frontend": {
"component": "frontend",
"dockerfile": "frontend/Dockerfile",
"context": "frontend",
"default_image_name": "termi-astro-frontend",
},
"admin": {
"component": "admin",
"dockerfile": "admin/Dockerfile",
"context": "admin",
"default_image_name": "termi-astro-admin",
},
}
components = [item for item in os.environ.get("COMPONENTS_CSV", "").split(",") if item] if [ -n "${SELECTED[admin]:-}" ]; then
matrix = {"include": [mapping[item] for item in components]} ADMIN_CHANGED=true
COUNT=$((COUNT + 1))
fi
print(f"matrix={json.dumps(matrix, separators=(',', ':'))}") {
print(f"count={len(components)}") echo "count=${COUNT}"
print(f"frontend_changed={'true' if 'frontend' in components else 'false'}") echo "backend_changed=${BACKEND_CHANGED}"
PY echo "frontend_changed=${FRONTEND_CHANGED}"
echo "admin_changed=${ADMIN_CHANGED}"
} >> "$GITHUB_OUTPUT"
echo "Selected components: ${COMPONENTS_CSV}" echo "Selected components: ${COMPONENTS_CSV}"
if [ -n "${CHANGED_FILES}" ]; then if [ -n "${CHANGED_FILES}" ]; then
@@ -135,19 +130,71 @@ jobs:
fi fi
build-and-push: build-and-push:
name: build-and-push (${{ matrix.component }})
needs: resolve-build-targets needs: resolve-build-targets
if: needs.resolve-build-targets.outputs.count != '0' if: needs.resolve-build-targets.outputs.count != '0'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 1 max-parallel: 1
matrix: ${{ fromJson(needs.resolve-build-targets.outputs.matrix) }} matrix:
include:
- component: backend
dockerfile: backend/Dockerfile
context: backend
default_image_name: termi-astro-backend
- component: frontend
dockerfile: frontend/Dockerfile
context: frontend
default_image_name: termi-astro-frontend
- component: admin
dockerfile: admin/Dockerfile
context: admin
default_image_name: termi-astro-admin
steps: steps:
- name: Decide whether to build current component
id: should_build
shell: bash
env:
COMPONENT: ${{ matrix.component }}
BACKEND_CHANGED: ${{ needs.resolve-build-targets.outputs.backend_changed }}
FRONTEND_CHANGED: ${{ needs.resolve-build-targets.outputs.frontend_changed }}
ADMIN_CHANGED: ${{ needs.resolve-build-targets.outputs.admin_changed }}
run: |
set -euo pipefail
SHOULD_BUILD=false
case "${COMPONENT}" in
backend)
SHOULD_BUILD="${BACKEND_CHANGED}"
;;
frontend)
SHOULD_BUILD="${FRONTEND_CHANGED}"
;;
admin)
SHOULD_BUILD="${ADMIN_CHANGED}"
;;
esac
echo "should_build=${SHOULD_BUILD}" >> "$GITHUB_OUTPUT"
echo "component=${COMPONENT}"
echo "should_build=${SHOULD_BUILD}"
- name: Skip unchanged component
if: steps.should_build.outputs.should_build != 'true'
shell: bash
env:
COMPONENT: ${{ matrix.component }}
run: echo "No changes detected for ${COMPONENT}, skipping docker build."
- name: Checkout - name: Checkout
if: steps.should_build.outputs.should_build == 'true'
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Resolve image metadata - name: Resolve image metadata
if: steps.should_build.outputs.should_build == 'true'
id: meta id: meta
shell: bash shell: bash
env: env:
@@ -217,6 +264,7 @@ jobs:
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: Login registry - name: Login registry
if: steps.should_build.outputs.should_build == 'true'
shell: bash shell: bash
env: env:
REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }} REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }}
@@ -271,6 +319,7 @@ jobs:
fi fi
- name: Setup docker buildx - name: Setup docker buildx
if: steps.should_build.outputs.should_build == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -284,6 +333,7 @@ jobs:
docker buildx inspect --bootstrap docker buildx inspect --bootstrap
- name: Login Docker Hub (optional) - name: Login Docker Hub (optional)
if: steps.should_build.outputs.should_build == 'true'
shell: bash shell: bash
env: env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -298,6 +348,7 @@ jobs:
fi fi
- name: Build and push image - name: Build and push image
if: steps.should_build.outputs.should_build == 'true'
shell: bash shell: bash
env: env:
COMPONENT: ${{ matrix.component }} COMPONENT: ${{ matrix.component }}
@@ -345,6 +396,7 @@ jobs:
"${CONTEXT_DIR}" "${CONTEXT_DIR}"
- name: Output image tags - name: Output image tags
if: steps.should_build.outputs.should_build == 'true'
shell: bash shell: bash
env: env:
COMPONENT: ${{ matrix.component }} COMPONENT: ${{ matrix.component }}

View File

@@ -516,4 +516,7 @@ function formatCommentDate(dateStr: string): string {
} else if (useCaptcha) { } else if (useCaptcha) {
void loadCaptcha(false); void loadCaptcha(false);
} }
wrapper?.setAttribute('data-comments-ready', 'true');
window.__termiCommentsReady = true;
</script> </script>

View File

@@ -273,6 +273,7 @@ const webPushPublicKey = popupSettings.webPushEnabled
const pathname = window.location.pathname || '/'; const pathname = window.location.pathname || '/';
const delayMs = Math.max(3000, Number(root.getAttribute('data-delay-ms') || '18000')); const delayMs = Math.max(3000, Number(root.getAttribute('data-delay-ms') || '18000'));
const defaultStatus = status instanceof HTMLElement ? status.textContent?.trim() || '' : ''; const defaultStatus = status instanceof HTMLElement ? status.textContent?.trim() || '' : '';
const isAutomatedBrowser = window.navigator.webdriver === true;
if ( if (
!(form instanceof HTMLFormElement) || !(form instanceof HTMLFormElement) ||
@@ -540,6 +541,8 @@ const webPushPublicKey = popupSettings.webPushEnabled
syncPopupOffset(); syncPopupOffset();
void syncBrowserPushState(); void syncBrowserPushState();
root.dataset.subscriptionPopupReady = 'true';
window.__termiSubscriptionPopupReady = true;
if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') { if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => syncPopupOffset()); const observer = new ResizeObserver(() => syncPopupOffset());
@@ -547,13 +550,16 @@ const webPushPublicKey = popupSettings.webPushEnabled
} }
window.addEventListener('resize', syncPopupOffset, { passive: true }); window.addEventListener('resize', syncPopupOffset, { passive: true });
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('pointerdown', markEngaged, { once: true, passive: true }); if (!isAutomatedBrowser) {
window.addEventListener('keydown', markEngaged, { once: true }); window.addEventListener('scroll', handleScroll, { passive: true });
window.setTimeout(() => { window.addEventListener('pointerdown', markEngaged, { once: true, passive: true });
autoReady = true; window.addEventListener('keydown', markEngaged, { once: true });
maybeAutoOpen(); window.setTimeout(() => {
}, delayMs); autoReady = true;
maybeAutoOpen();
}, delayMs);
}
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
const trigger = const trigger =

View File

@@ -1211,5 +1211,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
}); });
applyHomeFilters(false); applyHomeFilters(false);
document.body?.setAttribute('data-home-interactive-ready', 'true');
window.__termiHomeReady = true;
})(); })();
</script> </script>

View File

@@ -1,6 +1,13 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { getDebugState, patchAdminSiteSettings, resetMockState } from './helpers' import {
getDebugState,
patchAdminSiteSettings,
resetMockState,
waitForCommentsReady,
waitForHomeInteractive,
waitForSubscriptionPopupReady,
} from './helpers'
test.beforeEach(async ({ request }) => { test.beforeEach(async ({ request }) => {
await resetMockState(request) await resetMockState(request)
@@ -8,6 +15,7 @@ test.beforeEach(async ({ request }) => {
test('首页过滤、热门区和文章详情链路可用', async ({ page }) => { test('首页过滤、热门区和文章详情链路可用', async ({ page }) => {
await page.goto('/') await page.goto('/')
await waitForHomeInteractive(page)
await expect(page.locator('#home-results-count')).toContainText(/条结果/) await expect(page.locator('#home-results-count')).toContainText(/条结果/)
@@ -58,8 +66,10 @@ test('首页过滤、热门区和文章详情链路可用', async ({ page }) =>
test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => { test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => {
await page.goto('/articles/astro-terminal-blog') await page.goto('/articles/astro-terminal-blog')
await waitForCommentsReady(page)
await page.locator('#toggle-comment-form').click() await page.locator('#toggle-comment-form').click()
await expect(page.locator('#comment-form input[name="nickname"]')).toBeVisible()
await page.locator('#comment-form input[name="nickname"]').fill('Playwright Visitor') await page.locator('#comment-form input[name="nickname"]').fill('Playwright Visitor')
await page.locator('#comment-form input[name="email"]').fill('visitor@example.com') await page.locator('#comment-form input[name="email"]').fill('visitor@example.com')
await page.locator('#comment-form textarea[name="content"]').fill('这是一条来自回归测试的新评论。') await page.locator('#comment-form textarea[name="content"]').fill('这是一条来自回归测试的新评论。')
@@ -94,11 +104,13 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy() expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy()
await page.goto('/') await page.goto('/')
await waitForHomeInteractive(page)
await page.locator('[data-subscribe-form] input[name="displayName"]').fill('首页订阅用户') await page.locator('[data-subscribe-form] input[name="displayName"]').fill('首页订阅用户')
await page.locator('[data-subscribe-form] input[name="email"]').fill('inline-subscriber@example.com') await page.locator('[data-subscribe-form] input[name="email"]').fill('inline-subscriber@example.com')
await page.locator('[data-subscribe-form] button[type="submit"]').click() await page.locator('[data-subscribe-form] button[type="submit"]').click()
await expect(page.locator('[data-subscribe-status]')).toContainText('订阅') await expect(page.locator('[data-subscribe-status]')).toContainText('订阅')
await waitForSubscriptionPopupReady(page)
await page.locator('[data-subscription-popup-open]').click() await page.locator('[data-subscription-popup-open]').click()
await expect(page.locator('[data-subscription-popup-panel]')).toBeVisible() await expect(page.locator('[data-subscription-popup-panel]')).toBeVisible()
await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户') await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户')
@@ -136,6 +148,8 @@ test('GEO 分享面板、AI 摘要块与 llms 入口可用', async ({ page, requ
}) })
await page.goto('/') await page.goto('/')
await waitForHomeInteractive(page)
await waitForSubscriptionPopupReady(page)
await expect(page.locator('head link[rel="alternate"][href$="/llms.txt"]')).toHaveCount(1) await expect(page.locator('head link[rel="alternate"][href$="/llms.txt"]')).toHaveCount(1)
await expect(page.locator('head link[rel="alternate"][href$="/llms-full.txt"]')).toHaveCount(1) await expect(page.locator('head link[rel="alternate"][href$="/llms-full.txt"]')).toHaveCount(1)
await expect(page.getByRole('heading', { name: '给 AI 看的站点摘要' })).toBeVisible() await expect(page.getByRole('heading', { name: '给 AI 看的站点摘要' })).toBeVisible()

View File

@@ -33,6 +33,26 @@ export async function patchAdminSiteSettings(
return response.json() return response.json()
} }
export async function waitForHomeInteractive(page: Page) {
await page.waitForFunction(
() => (window as Window & { __termiHomeReady?: boolean }).__termiHomeReady === true,
)
}
export async function waitForCommentsReady(page: Page) {
await page.waitForFunction(
() => (window as Window & { __termiCommentsReady?: boolean }).__termiCommentsReady === true,
)
}
export async function waitForSubscriptionPopupReady(page: Page) {
await page.waitForFunction(
() =>
(window as Window & { __termiSubscriptionPopupReady?: boolean })
.__termiSubscriptionPopupReady === true,
)
}
export async function loginAdmin(page: Page) { export async function loginAdmin(page: Page) {
await page.goto('/login') await page.goto('/login')
await page.getByLabel('用户名').fill('admin') await page.getByLabel('用户名').fill('admin')