ci: stop duplicate post-merge validation (#4469)

This commit is contained in:
PerishFire
2026-06-17 10:04:03 +00:00
committed by GitHub
parent b49c9b7932
commit c782aeb3bb
13 changed files with 62 additions and 782 deletions
-19
View File
@@ -3,15 +3,6 @@ name: ci
on:
pull_request:
merge_group:
# Release validation is owned by the release workflows rather than this CI
# workflow: `release-stable` has a verify job before publishing, and
# `release-beta` builds from its selected release commit. Keep this trigger
# focused on PRs, main, and manual reruns instead of duplicating tag/release
# events that would run after those release workflows have already selected
# or validated their commit.
push:
branches:
- main
workflow_dispatch:
inputs:
ci_mode:
@@ -123,16 +114,6 @@ jobs:
gh api --paginate \
"repos/${{ github.repository }}/compare/${{ github.event.merge_group.base_sha }}...${{ github.event.merge_group.head_sha }}" \
--jq '(.files // [])[] | select(.status != "removed") | .filename' > "$changed_files"
elif [ "${{ github.event_name }}" = "push" ]; then
before="${{ github.event.before }}"
after="${{ github.sha }}"
if [[ "$before" =~ ^0+$ ]]; then
git ls-files > "$changed_files"
else
gh api --paginate \
"repos/${{ github.repository }}/compare/${before}...${after}" \
--jq '(.files // [])[] | select(.status != "removed") | .filename' > "$changed_files"
fi
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
gh api --paginate \
"repos/${{ github.repository }}/compare/main...${{ github.sha }}" \
+2 -9
View File
@@ -2,8 +2,7 @@ name: Docker image
# Phase 5 / spec §15.5 — multi-arch image builds.
#
# Pushes to ghcr.io on:
# - tag (v*.*.*) → ghcr.io/<owner>/od:<tag> + :latest
# Pushes to ghcr.io on release tags and when invoked by release automation.
on:
workflow_call:
@@ -24,7 +23,6 @@ on:
type: boolean
default: false
push:
branches: [main]
tags: ['v*.*.*']
jobs:
@@ -62,17 +60,13 @@ jobs:
with:
images: ghcr.io/${{ github.repository_owner }}/od
# spec §15.1 tag scheme:
# - main → :edge + :sha-<short>
# - vX.Y.Z push → :X.Y.Z + :latest
# - workflow_call (stable) → :<release_version> + optional :latest
# - pull_request → metadata only, no push
tags: |
type=semver,pattern={{version}}
type=raw,value=${{ inputs.release_version }},enable=${{ inputs.release_version != '' }}
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' && inputs.release_version == '' }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ inputs.publish_latest == true }}
type=sha,prefix=sha-,format=short,enable=${{ github.ref == 'refs/heads/main' && inputs.release_version == '' }}
- name: Build and push
uses: docker/build-push-action@v6
@@ -81,8 +75,7 @@ jobs:
file: deploy/Dockerfile
# spec §15.1 — multi-arch single manifest
platforms: linux/amd64,linux/arm64
# PR builds smoke-test the build only; merges to main /
# tags publish.
# Tags and release workflow calls publish.
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-57
View File
@@ -1,57 +0,0 @@
name: nix-check
on:
push:
branches:
- main
paths-ignore:
- '**/*.md'
- '**/*.mdx'
- '**/*.txt'
- LICENSE
- .gitignore
- .editorconfig
- .vscode/**
- .idea/**
- docs/**
- assets/**
- '**/*.png'
- '**/*.jpg'
- '**/*.jpeg'
- '**/*.gif'
- '**/*.svg'
- '**/*.webp'
- .github/ISSUE_TEMPLATE/**
- .github/PULL_REQUEST_TEMPLATE.md
- .github/CODEOWNERS
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
extra_nix_config: |
experimental-features = nix-command flakes
accept-flake-config = true
# - name: Configure Cachix (always pull; push only with token)
# uses: cachix/cachix-action@v15
# with:
# name: nexu-open-design
# # Empty token = read-only mode. Forks/PRs from external
# # contributors still get cache hits but cannot push back.
# authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
# skipPush: ${{ secrets.CACHIX_AUTH_TOKEN == '' }}
- name: nix flake check
run: nix flake check --print-build-logs --keep-going
+3 -3
View File
@@ -2,7 +2,7 @@ name: nix-hash-autofix
on:
workflow_run:
workflows: [ci-nix]
workflows: [ci]
types: [completed]
permissions:
@@ -183,7 +183,7 @@ jobs:
printf '%s\n' "$marker"
if [ "${HASH_REFRESH_STATUS}" = 'patch-generated' ]; then
printf "A generated Nix hash refresh is available for this PR.\n\n"
printf "Apply the patch from the \`nix-hash-refresh\` artifact attached to the failed \`ci-nix\` run, or paste the diff below into \`git apply\`:\n\n"
printf "Apply the patch from the \`nix-hash-refresh\` artifact attached to the failed \`ci\` run, or paste the diff below into \`git apply\`:\n\n"
printf '```diff\n'
if [ -f "$patch_file" ]; then
cat "$patch_file"
@@ -192,7 +192,7 @@ jobs:
fi
printf '\n```\n'
else
printf "The failed \`ci-nix\` run attempted to refresh \`nix/pnpm-deps.nix\`, but it could not produce a hash-only patch.\n\n"
printf "The failed \`ci\` run attempted to refresh \`nix/pnpm-deps.nix\`, but it could not produce a hash-only patch.\n\n"
printf "Download the \`nix-hash-refresh\` artifact from that run and inspect \`update.log\` for details.\n"
fi
} > "$body_file"
-658
View File
@@ -1,658 +0,0 @@
name: Notify main CI failure
on:
workflow_dispatch:
inputs:
send_test:
description: Send a test Feishu alert card.
required: true
default: 'true'
type: choice
options:
- 'true'
- 'false'
workflow_run:
workflows:
- ci
types:
- completed
permissions:
actions: read
contents: read
pull-requests: read
jobs:
notify:
name: Notify Feishu
runs-on: ubuntu-latest
if: >-
${{
(
github.event_name == 'workflow_dispatch' &&
inputs.send_test == 'true'
) ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
contains(fromJSON('["failure","timed_out"]'), github.event.workflow_run.conclusion)
)
}}
steps:
- name: Send main CI failure alert
uses: actions/github-script@v7
env:
FEISHU_WEBHOOK: ${{ secrets.FEISHU_MAIN_CI_WEBHOOK }}
with:
script: |
const webhook = process.env.FEISHU_WEBHOOK;
if (!webhook) {
core.setFailed('Missing FEISHU_MAIN_CI_WEBHOOK secret.');
return;
}
const owner = context.repo.owner;
const repo = context.repo.repo;
const isTest = context.eventName === 'workflow_dispatch';
const run = isTest
? {
id: context.runId,
name: 'main-ci-alert-test',
conclusion: 'failure',
event: 'workflow_dispatch',
head_branch: context.ref.replace(/^refs\/heads\//, ''),
head_sha: context.sha,
run_started_at: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
display_title: 'Manual Feishu alert smoke test',
html_url: `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
actor: context.actor ? { login: context.actor } : null,
head_commit: {
message: 'Manual Feishu alert smoke test',
author: { name: context.actor || 'unknown' },
},
}
: context.payload.workflow_run;
if (!isTest && run.conclusion === 'cancelled') {
core.info('Skipping cancelled workflow run.');
return;
}
const startedAt = new Date(run.run_started_at || run.created_at).getTime();
const updatedAt = new Date(run.updated_at).getTime();
const durationMinutes = Number.isFinite(startedAt) && Number.isFinite(updatedAt)
? Math.round((updatedAt - startedAt) / 60000)
: null;
const problemJobs = isTest
? [{ name: 'Feishu notification smoke test', conclusion: 'failure' }]
: (await github.rest.actions.listJobsForWorkflowRun({
owner,
repo,
run_id: run.id,
per_page: 100,
})).data.jobs.filter((job) => ['failure', 'timed_out'].includes(job.conclusion));
if (!isTest && problemJobs.length === 0) {
core.info('Skipping run with no failed or timed-out jobs.');
return;
}
const failedJobs = problemJobs
.map((job) => `- ${job.name} (${job.conclusion})`)
.join('\n') || '- Unknown';
function textFromLogData(data) {
if (typeof data === 'string') {
return data;
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString('utf8');
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer).toString('utf8');
}
return String(data || '');
}
function countMatches(text, patterns) {
return patterns.reduce((count, pattern) => count + (pattern.test(text) ? 1 : 0), 0);
}
function sampleMatches(text, patterns, labels) {
const matches = [];
patterns.forEach((pattern, index) => {
if (pattern.test(text)) {
matches.push(labels[index]);
}
});
return matches;
}
const playwrightUiFailureTitlePattern = /(?:\d+\)\s+|\[[^\]]+\]\s+)?(?:\[chromium\]\s+)?[›>]\s+(ui\/[^›]+)\s+›\s+(.+)/;
function extractTopFailures(text) {
const failures = [];
for (const line of text.split('\n')) {
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
const match = clean.match(playwrightUiFailureTitlePattern);
if (match) {
failures.push(`${match[1]} › ${match[2].replace(/\s+/g, ' ')}`);
}
if (failures.length >= 4) {
break;
}
}
return [...new Set(failures)];
}
function extractFailureSignalText(text) {
const lines = text.split('\n');
const selected = [];
let contextUntil = -1;
const startPatterns = [
playwrightUiFailureTitlePattern,
/##\[error\]/,
/^\s*Error:/,
/Test timeout of \d+ms exceeded/i,
/strict mode violation/i,
/Process from config\.webServer was not able to start/i,
/browserType\.launch|Executable doesn't exist|playwright install/i,
/Cannot find module|MODULE_NOT_FOUND/i,
];
const contextPatterns = [
/Call log:/,
/^\s*-\s+(waiting for|locator resolved to|unexpected value|expect\.to)/i,
/^\s+at\s+/,
/Received:|Expected:/,
];
lines.forEach((line, index) => {
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trimEnd();
if (startPatterns.some((pattern) => pattern.test(clean))) {
selected.push(clean);
contextUntil = Math.max(contextUntil, index + 14);
return;
}
if (index <= contextUntil || contextPatterns.some((pattern) => pattern.test(clean))) {
selected.push(clean);
}
});
return selected.join('\n').slice(0, 120_000);
}
function assertFailureSignalFixtures() {
const fixtures = [
{
name: 'browser-prefixed Playwright title',
log: [
"1) [chromium] › ui/example-prefixed.test.ts:1:1 › [P0] prefixed failure",
"Error: locator.click: Test timeout of 30000ms exceeded.",
].join('\n'),
expected: '[P0] prefixed failure',
},
{
name: 'no-browser Playwright title',
log: [
"1) › ui/example-unprefixed.test.ts:1:1 › [P0] unprefixed failure",
"Error: locator.click: Test timeout of 30000ms exceeded.",
].join('\n'),
expected: '[P0] unprefixed failure',
},
];
for (const fixture of fixtures) {
const signalText = extractFailureSignalText(fixture.log);
if (!signalText.includes(fixture.expected)) {
throw new Error(`Failure signal fixture failed for ${fixture.name}`);
}
}
}
assertFailureSignalFixtures();
function classifyFailure({ runName, conclusion, problemJobs, logs }) {
const combined = logs.join('\n').slice(0, 250_000);
const jobText = problemJobs.map((job) => `${job.name} ${job.conclusion}`).join('\n');
const failureSignals = extractFailureSignalText(combined);
const text = `${jobText}\n${failureSignals || combined}`;
const stalePatterns = [
/strict mode violation/i,
/element\(s\) not found/i,
/waiting for getBy(TestId|Role|Text|Label|Placeholder)/i,
/not an input element/i,
/locator\.(click|fill|selectOption|check|hover): Test timeout/i,
/getByTestId\('new-conversation'\)/i,
/Open settings\|.*Account & settings/i,
/AMR v\|AMR CLI|onboarding-view__setup-panel/i,
];
const staleLabels = [
'选择器 strict mode 命中多个元素',
'页面元素/文案未按旧选择器出现',
'失败卡在 getBy* 定位等待',
'控件类型已从原生 input/select 变更',
'locator 交互阶段超时',
'仍在依赖旧 new-conversation testid',
'设置入口选择器命中过宽',
'仍在依赖旧 AMR/onboarding UI',
];
const productPatterns = [
/expect\(received\)\.(toEqual|toBe|toContain|toMatch)/i,
/Received:\s+\[\]/i,
/Generation failed|No output|provider ended the request/i,
/\b(500|502|503|504)\b|Internal Server Error|Bad Gateway/i,
/artifact.*not found|file.*not found|conversation.*not found/i,
/toHaveCount\(expected\).*Received:\s+0/i,
];
const productLabels = [
'业务结果断言不符合预期',
'预期产物/文件列表为空',
'生成链路返回失败或空输出',
'服务端/API 返回 5xx',
'核心业务对象缺失',
'预期实体数量为 0',
];
const infraPatterns = [
/Process from config\.webServer was not able to start/i,
/command failed:.*pnpm.*build|ELIFECYCLE|ERR_PNPM/i,
/browserType\.launch|Executable doesn't exist|playwright install/i,
/No space left on device|ENOSPC/i,
/Cannot find module|MODULE_NOT_FOUND|node_modules/i,
];
const infraLabels = [
'Playwright webServer 启动失败',
'构建/包管理命令失败',
'浏览器安装或启动失败',
'Runner 磁盘空间不足',
'依赖或模块缺失',
];
const flakyPatterns = [
/Test timeout of \d+ms exceeded/i,
/Timeout \d+ms exceeded while waiting/i,
/page\.waitFor(Response|Request|URL).*Timeout|Test ended/i,
/ECONNRESET|ETIMEDOUT|socket hang up/i,
];
const flakyLabels = [
'测试整体超时',
'等待条件超时',
'请求/响应等待超时',
'网络连接短暂异常',
];
const infraReasons = sampleMatches(text, infraPatterns, infraLabels);
const staleReasons = sampleMatches(text, stalePatterns, staleLabels);
const productReasons = sampleMatches(text, productPatterns, productLabels);
const flakyReasons = sampleMatches(text, flakyPatterns, flakyLabels);
const infraScore = countMatches(text, infraPatterns);
const staleScore = countMatches(text, stalePatterns);
const productScore = countMatches(text, productPatterns);
const flakyScore = countMatches(text, flakyPatterns);
const failedTests = extractTopFailures(text);
const failedJobNames = problemJobs.map((job) => job.name);
let category = '需要人工判断';
let confidence = '低';
let suggestedAction = '先打开失败 job 的 Playwright report 和日志确认首个真实失败。';
let reasons = [];
if (conclusion === 'timed_out' || problemJobs.some((job) => job.conclusion === 'timed_out')) {
category = '可能是超时/资源问题';
confidence = flakyScore >= 2 ? '中' : '低';
reasons = flakyReasons;
suggestedAction = '先看是否集中在长耗时 shard;必要时继续拆分或提高单 job 超时。';
}
if (infraScore > 0) {
category = '环境/CI 启动问题';
confidence = infraScore >= 2 ? '高' : '中';
reasons = infraReasons;
suggestedAction = '优先修复 build、依赖、Playwright browser 或 webServer 启动。';
} else if (staleScore >= Math.max(2, productScore + 1)) {
category = '疑似用例过时/选择器漂移';
confidence = staleScore >= 3 || failedJobNames.length >= 2 ? '高' : '中';
reasons = staleReasons;
suggestedAction = '优先对齐测试选择器、文案或等待条件,再判断是否有产品回归。';
} else if (productScore > staleScore) {
category = '疑似产品回归';
confidence = productScore >= 2 ? '中' : '低';
reasons = productReasons;
suggestedAction = '优先复现对应 P0 流程,检查最近合入的业务代码和接口响应。';
} else if (flakyScore > 0) {
category = '可能 flaky/等待不足';
confidence = problemJobs.length === 1 ? '中' : '低';
reasons = flakyReasons;
suggestedAction = '先 rerun failed jobs;若重现,再补稳定等待或拆分耗时 case。';
}
if (runName !== 'ui-extended-main' && infraScore === 0 && staleScore === 0 && productScore === 0) {
category = '非 UI CI 失败';
confidence = '低';
suggestedAction = '查看失败 job 日志定位 build/test/lint 具体阶段。';
}
if (!reasons.length) {
reasons = ['未命中明确规则'];
}
return {
category,
confidence,
reasons: reasons.slice(0, 4),
failedTests,
suggestedAction,
};
}
function normalizeFingerprintPart(value) {
return String(value || '')
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function failureFingerprint({ runName, conclusion, problemJobs, triage }) {
const signals = (triage.failedTests.length ? triage.failedTests : triage.reasons)
.map(normalizeFingerprintPart)
.sort();
const fallbackJobs = triage.failedTests.length
? []
: problemJobs
.map((job) => `${normalizeFingerprintPart(job.name)}:${normalizeFingerprintPart(job.conclusion)}`)
.sort();
return JSON.stringify({
workflow: normalizeFingerprintPart(runName),
conclusion: conclusion === 'timed_out' ? 'timed_out' : 'failure',
category: normalizeFingerprintPart(triage.category),
fallbackJobs,
signals,
});
}
function assertFailureFingerprintFixtures() {
const baseTriage = {
category: '非 UI CI 失败',
confidence: '低',
reasons: ['未命中明确规则'],
failedTests: [],
suggestedAction: '查看失败 job 日志定位 build/test/lint 具体阶段。',
};
const lintFingerprint = failureFingerprint({
runName: 'ci',
conclusion: 'failure',
problemJobs: [{ name: 'lint', conclusion: 'failure' }],
triage: baseTriage,
});
const unitFingerprint = failureFingerprint({
runName: 'ci',
conclusion: 'failure',
problemJobs: [{ name: 'unit tests', conclusion: 'failure' }],
triage: baseTriage,
});
if (lintFingerprint === unitFingerprint) {
throw new Error('Non-UI fallback fingerprints must include failed job discriminators');
}
const uiTriage = {
...baseTriage,
category: '疑似用例过时/选择器漂移',
failedTests: ['ui/example.test.ts:1:1 › [P0] stale selector'],
};
const uiFingerprint = failureFingerprint({
runName: 'ui-extended-main',
conclusion: 'failure',
problemJobs: [{ name: 'ui shard 1', conclusion: 'failure' }],
triage: uiTriage,
});
if (!JSON.parse(uiFingerprint).fallbackJobs || JSON.parse(uiFingerprint).fallbackJobs.length !== 0) {
throw new Error('UI fingerprints with parsed failed tests should not drift by job name');
}
}
assertFailureFingerprintFixtures();
async function shouldSkipDuplicateAlert({ currentRun, currentFingerprint }) {
if (isTest || !currentRun.workflow_id) return false;
const windowMs = 24 * 60 * 60 * 1000;
const currentCreatedAt = new Date(currentRun.created_at || currentRun.run_started_at || currentRun.updated_at).getTime();
const cutoff = Number.isFinite(currentCreatedAt)
? currentCreatedAt - windowMs
: Date.now() - windowMs;
let recentRuns = [];
try {
recentRuns = (await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: currentRun.workflow_id,
branch: 'main',
event: 'push',
status: 'completed',
per_page: 20,
})).data.workflow_runs;
} catch (error) {
core.warning(`Failed to list recent workflow runs for duplicate check: ${error.message}`);
return false;
}
recentRuns.sort((left, right) => {
const leftCreatedAt = new Date(left.created_at || left.run_started_at || left.updated_at).getTime();
const rightCreatedAt = new Date(right.created_at || right.run_started_at || right.updated_at).getTime();
return (Number.isFinite(rightCreatedAt) ? rightCreatedAt : 0) - (Number.isFinite(leftCreatedAt) ? leftCreatedAt : 0);
});
for (const candidate of recentRuns) {
if (candidate.id === currentRun.id) continue;
const candidateCreatedAt = new Date(candidate.created_at || candidate.run_started_at || candidate.updated_at).getTime();
if (Number.isFinite(currentCreatedAt) && Number.isFinite(candidateCreatedAt) && candidateCreatedAt > currentCreatedAt) continue;
if (Number.isFinite(candidateCreatedAt) && candidateCreatedAt < cutoff) break;
if (candidate.conclusion === 'success') return false;
if (!['failure', 'timed_out'].includes(candidate.conclusion)) continue;
let candidateJobs = [];
try {
candidateJobs = (await github.rest.actions.listJobsForWorkflowRun({
owner,
repo,
run_id: candidate.id,
per_page: 100,
})).data.jobs.filter((job) => ['failure', 'timed_out'].includes(job.conclusion));
} catch (error) {
core.warning(`Failed to list jobs for duplicate check run ${candidate.id}: ${error.message}`);
continue;
}
if (candidateJobs.length === 0) continue;
const candidateLogTexts = [];
for (const job of candidateJobs.slice(0, 6)) {
try {
const logResponse = await github.rest.actions.downloadJobLogsForWorkflowRun({
owner,
repo,
job_id: job.id,
});
candidateLogTexts.push(textFromLogData(logResponse.data).slice(-80_000));
} catch (error) {
core.warning(`Failed to download logs for duplicate check job ${job.name}: ${error.message}`);
}
}
const candidateTriage = classifyFailure({
runName: candidate.name,
conclusion: candidate.conclusion,
problemJobs: candidateJobs,
logs: candidateLogTexts,
});
const candidateFingerprint = failureFingerprint({
runName: candidate.name,
conclusion: candidate.conclusion,
problemJobs: candidateJobs,
triage: candidateTriage,
});
if (candidateFingerprint === currentFingerprint) {
core.info(`Skipping duplicate CI alert; same failure fingerprint already appeared in run ${candidate.id}: ${candidate.html_url}`);
return true;
}
}
return false;
}
const logTexts = [];
if (isTest) {
logTexts.push([
"1) [chromium] › ui/example-stale.test.ts:1:1 › [P0] sample stale selector",
"Error: locator.click: Test timeout of 30000ms exceeded.",
"Call log:",
" - waiting for getByRole('dialog').getByRole('tab', { name: /Old Settings/i })",
].join('\n'));
} else {
for (const job of problemJobs.slice(0, 6)) {
try {
const logResponse = await github.rest.actions.downloadJobLogsForWorkflowRun({
owner,
repo,
job_id: job.id,
});
logTexts.push(textFromLogData(logResponse.data).slice(-80_000));
} catch (error) {
core.warning(`Failed to download logs for ${job.name}: ${error.message}`);
}
}
}
const triage = classifyFailure({
runName: run.name,
conclusion: run.conclusion,
problemJobs,
logs: logTexts,
});
const currentFingerprint = failureFingerprint({
runName: run.name,
conclusion: run.conclusion,
problemJobs,
triage,
});
if (await shouldSkipDuplicateAlert({ currentRun: run, currentFingerprint })) {
return;
}
const topFailuresText = triage.failedTests.length
? triage.failedTests.map((failure) => `- ${failure}`).join('\n')
: '- 未从日志解析到具体失败用例';
const pull = isTest
? null
: (await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: run.head_sha,
})).data[0];
const prText = pull
? `#${pull.number} ${pull.title}\n${pull.html_url}`
: (isTest ? '手动测试,无关联 PR' : '未解析到关联 PR');
const shortSha = run.head_sha.slice(0, 7);
const author = run.head_commit?.author?.name || run.actor?.login || 'unknown';
const subject = run.head_commit?.message?.split('\n')[0] || run.display_title || 'unknown';
const repoName = `${owner}/${repo}`;
const title = isTest
? '[TEST] Open Design Main CI alert'
: run.name === 'ui-extended-main'
? (run.conclusion === 'cancelled'
? 'Open Design Main UI regression stopped after failures'
: 'Open Design Main UI regression failed')
: 'Open Design Main CI failed';
const body = {
msg_type: 'interactive',
card: {
config: {
wide_screen_mode: true,
},
header: {
template: 'red',
title: {
tag: 'plain_text',
content: title,
},
},
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: [
`**Repository:** ${repoName}`,
`**Branch:** ${isTest ? run.head_branch : 'main'}`,
`**Workflow:** ${run.name}`,
`**Conclusion:** ${run.conclusion}`,
`**Duration:** ${durationMinutes === null ? 'unknown' : `${durationMinutes}m`}`,
`**Commit:** ${shortSha}`,
`**Author:** ${author}`,
`**Subject:** ${subject}`,
`**Related PR:** ${prText}`,
].join('\n'),
},
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: [
`**Triage:** ${triage.category}`,
`**Confidence:** ${triage.confidence}`,
`**Why:** ${triage.reasons.join(';')}`,
`**Suggested action:** ${triage.suggestedAction}`,
`**Top failures:**\n${topFailuresText}`,
].join('\n'),
},
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**Failed jobs:**\n${failedJobs}`,
},
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: 'Open GitHub Actions run',
},
type: 'primary',
url: run.html_url,
},
],
},
],
},
};
const response = await fetch(webhook, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
const responseText = await response.text();
let responseJson = null;
try {
responseJson = JSON.parse(responseText);
} catch {
responseJson = null;
}
const feishuCode = responseJson?.StatusCode ?? responseJson?.code;
const hasFeishuError = feishuCode !== undefined && Number(feishuCode) !== 0;
if (!response.ok || hasFeishuError) {
core.setFailed(`Feishu notification failed: ${response.status} ${responseText}`);
return;
}
core.info(`Feishu notification accepted: ${response.status} ${responseText}`);
+11 -1
View File
@@ -30,7 +30,17 @@ jobs:
# Fork PR capture artifacts are untrusted. Always validate the live PR state
# and only execute trusted base-repository code before publishing comments
# or reports.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure' }}
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
github.event.workflow_run.event == 'pull_request' &&
(
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
)
)
}}
runs-on: ubuntu-24.04
timeout-minutes: 15
+2 -2
View File
@@ -155,7 +155,7 @@ These notes capture the product/implementation answers that otherwise get lost b
| `tools/pack/helm/open-design/templates/**` | shipped | Phase 5 — Deployment / Service / Secret / ConfigMap / PVCs / Ingress / NOTES |
| `tools/pack/helm/open-design/values-{aws,gcp,azure,aliyun,tencent,huawei,self}.yaml` | shipped | Phase 5 — per-cloud overrides (volume + ingress diffs) |
| `deploy/Dockerfile` plugins/_official COPY | shipped | Phase 5 — bundled atoms travel with the image |
| `.github/workflows/docker-image.yml` | shipped | Phase 5 — multi-arch ghcr.io push (:edge / :version) |
| `.github/workflows/docker-image.yml` | shipped | Phase 5 — multi-arch ghcr.io push (:version / :latest) |
| `apps/daemon/src/storage/project-storage.ts` | shipped | Phase 5 — ProjectStorage interface + Local impl + S3 stub |
| `apps/daemon/src/storage/daemon-db.ts` | shipped | Phase 5 — DaemonDb config resolver (sqlite default, postgres stub) |
| `GET /api/plugins/:id/asset/*` | shipped | Phase 4 — sandboxed plugin asset endpoint (§9.2 CSP) |
@@ -506,7 +506,7 @@ Validation
Deliverables
- [x] `linux/amd64` + `linux/arm64` Dockerfile per spec §15.1 (`deploy/Dockerfile`; entry-slice base is `node:24-alpine` with `NODE_IMAGE` build-arg override → `node:24-bookworm-slim`; bundled atom plugins ship inside the image).
- [x] CI pushes `:edge` on main, `:<version>` on tag — `.github/workflows/docker-image.yml`.
- [x] Release automation pushes `:<version>` / `:latest`; tag pushes publish matching images — `.github/workflows/docker-image.yml`.
- [x] `tools/pack/docker-compose.yml`, `tools/pack/helm/` — chart templates (Deployment / Service / Secret / ConfigMap / PVCs / Ingress / NOTES) shipped, per-cloud `values-<cloud>.yaml` overrides shipped (AWS / GCP / Azure / Aliyun / Tencent / Huawei / self-hosted).
- [x] Bound-API-token guard: daemon refuses to bind `OD_BIND_HOST=<non-loopback>` without `OD_API_TOKEN`; bearer middleware on `/api/*` skipped only on loopback peers and on the open probes (`/api/health`, `/api/version`, `/api/daemon/status`).
- [x] `ProjectStorage` adapter substrate — `LocalProjectStorage` (v1 default) wired + tested; `S3ProjectStorage` interface-locked stub; `resolveProjectStorage` reads `OD_PROJECT_STORAGE`. AWS SDK wiring stays as the next Phase 5 PR.
+2 -2
View File
@@ -1608,7 +1608,7 @@ OD ships as a single multi-arch Docker image so the full plugin/marketplace syst
### 15.1 Image shape
- **Tag**: `ghcr.io/nexu-io/od:<version>` plus moving `:latest` and `:edge`.
- **Tag**: `ghcr.io/nexu-io/od:<version>` plus moving `:latest`.
- **Architectures**: `linux/amd64` and `linux/arm64` (single manifest list).
- **Contents**:
- Node 24 runtime + the daemon `dist/` bundle.
@@ -1849,7 +1849,7 @@ Validation: (a) install a published plugin → export from a real project that u
This phase is independent of Phases 1–4 and can run in parallel as soon as Phase 1 lands (since the headless mode and the daemon contract are stable from Phase 1 on).
- **Container image (week 1):** multi-arch `linux/amd64` + `linux/arm64` Dockerfile with the contents listed in §15.1; CI to push `:edge` on every main commit and `:<version>` on tag.
- **Container image (week 1):** multi-arch `linux/amd64` + `linux/arm64` Dockerfile with the contents listed in §15.1; release automation publishes `:<version>` and `:latest`, and tag pushes publish matching images.
- **Reference manifests:** `tools/pack/docker-compose.yml` and `tools/pack/helm/`. The compose file demonstrates the daemon + reverse proxy pattern; the Helm chart parameterizes volume + secret patterns for any cloud.
- **Bound-API-token guard (new in Phase 5):** daemon refuses to bind `OD_BIND_HOST=0.0.0.0` without `OD_API_TOKEN`; bearer-token middleware on `/api/*` (skipped only when host is loopback).
- **`ProjectStorage` adapter for S3-compatible blob stores** (works for AWS S3, GCS S3-compat, Azure Blob via shim, Aliyun OSS, Tencent COS, Huawei OBS).
+2 -2
View File
@@ -1603,7 +1603,7 @@ OD 以单个 multi-arch Docker image 发布,使完整 plugin/marketplace syste
### 15.1 Image shape
- **Tag**:`ghcr.io/nexu-io/od:<version>`,以及 moving `:latest` 与 `:edge`。
- **Tag**:`ghcr.io/nexu-io/od:<version>`,以及 moving `:latest`。
- **Architectures**:`linux/amd64` 与 `linux/arm64`(single manifest list)。
- **Contents**:
- Node 24 runtime + daemon `dist/` bundle。
@@ -1837,7 +1837,7 @@ Validation:
此 phase 独立于 Phases 1–4;Phase 1 落地后即可并行,因为 headless mode 与 daemon contract 从 Phase 1 起就稳定。
- **Container image(第 1 周):** multi-arch `linux/amd64` + `linux/arm64` Dockerfile,内容见 §15.1;CI 在每次 main commit 推 `:edge`,tag 时推 `:<version>`。
- **Container image(第 1 周):** multi-arch `linux/amd64` + `linux/arm64` Dockerfile,内容见 §15.1;release automation 发布 `:<version>` 与 `:latest`,tag push 发布对应 image。
- **Reference manifests:** `tools/pack/docker-compose.yml` 与 `tools/pack/helm/`。compose file 展示 daemon + reverse proxy pattern;Helm chart 参数化任意云的 volume + secret patterns。
- **Bound-API-token guard(Phase 5 新增能力):** daemon 在没有 `OD_API_TOKEN` 时拒绝绑定 `OD_BIND_HOST=0.0.0.0`;`/api/*` 上 bearer-token middleware(仅 loopback host 跳过)。
- **S3-compatible blob stores 的 `ProjectStorage` adapter**(适用于 AWS S3、GCS S3-compat、Azure Blob via shim、Aliyun OSS、Tencent COS、Huawei OBS)。
+30
View File
@@ -13,6 +13,9 @@ const execFileAsync = promisify(execFile);
const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const workspaceRoot = dirname(e2eRoot);
const ciWorkflowPath = join(workspaceRoot, ".github", "workflows", "ci.yml");
const dockerImageWorkflowPath = join(workspaceRoot, ".github", "workflows", "docker-image.yml");
const nixHashAutofixWorkflowPath = join(workspaceRoot, ".github", "workflows", "nix-hash-autofix.yml");
const visualPrCommentWorkflowPath = join(workspaceRoot, ".github", "workflows", "visual-pr-comment.yml");
const releaseBetaWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-beta.yml");
const releaseBetaSelfHostedWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-beta-s.yml");
const releasePreviewWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-preview.yml");
@@ -154,6 +157,33 @@ describe("packaged smoke workflow", () => {
expect(blobGuard).toContain("select(.status != \"removed\") | .filename");
});
it("[P2] keeps merge queue as the authoritative post-PR validation path", async () => {
const [ciWorkflow, dockerWorkflow, visualCommentWorkflow, nixAutofixWorkflow] = await Promise.all([
readFile(ciWorkflowPath, "utf8"),
readFile(dockerImageWorkflowPath, "utf8"),
readFile(visualPrCommentWorkflowPath, "utf8"),
readFile(nixHashAutofixWorkflowPath, "utf8"),
]);
const ciTrigger = sectionBetween(ciWorkflow, "on:", "\npermissions:");
const ciBlobGuard = sectionBetween(ciWorkflow, " static_gate:", " nix_validation:");
const dockerTrigger = sectionBetween(dockerWorkflow, "on:", "\njobs:");
const visualCommentJob = sectionBetween(visualCommentWorkflow, " comment:", "\n runs-on:");
expect(ciTrigger).toContain("pull_request:");
expect(ciTrigger).toContain("merge_group:");
expect(ciTrigger).toContain("workflow_dispatch:");
expect(ciTrigger).not.toContain("push:");
expect(ciBlobGuard).not.toContain('${{ github.event_name }}" = "push"');
expect(dockerTrigger).toContain("workflow_call:");
expect(dockerTrigger).toContain("tags: ['v*.*.*']");
expect(dockerTrigger).not.toContain("branches: [main]");
expect(dockerTrigger).not.toContain("- main");
expect(visualCommentJob).toContain("github.event.workflow_run.event == 'pull_request'");
expect(nixAutofixWorkflow).toContain("workflows: [ci]");
expect(nixAutofixWorkflow).not.toContain("ci-nix");
});
it("[P2] keeps PR and merge queue CI separated by hot/full validation mode", async () => {
const workflow = await readFile(ciWorkflowPath, "utf8");
const scopes = sectionBetween(workflow, " scopes:", " static_gate:");
+5 -9
View File
@@ -241,15 +241,11 @@ installing the workspace.
## CI
`.github/workflows/nix-check.yml` runs `nix flake check` on pushes to
`main` and can also be started manually with `workflow_dispatch`.
Pull requests that touch Nix inputs, daemon/web Nix build closures, or the
generated hash maintenance workflows are validated earlier in
`.github/workflows/ci.yml` via the required `Validate workspace` gate.
That PR path runs `nix flake check` for `flake.*`, `nix/**`, root lock and
workspace manifests, and files that are actually in the daemon/web Nix
closures. The flake also filters each derivation down to only the workspace
`.github/workflows/ci.yml` owns Nix validation through the required
`Validate workspace` gate. Pull requests run `nix flake check` only when they
touch Nix inputs, daemon/web Nix build closures, or generated hash maintenance
workflows. Merge queue and manual full CI runs execute the full Nix path before
merge. The flake also filters each derivation down to only the workspace
packages it actually installs, so unrelated package/tool changes stay off the
slower Nix path and do not churn the other derivation's pnpm store hash.
+3 -18
View File
@@ -91,17 +91,6 @@ function createScopePlan(): ScopePlan {
applyChangedFile(file, outputs);
if (allScopeOutputsTrue(outputs)) break;
}
} else if (eventName === "push") {
outputs.daemon_tests_required = true;
outputs.web_tests_required = true;
outputs.tools_dev_tests_required = true;
outputs.tools_pack_tests_required = true;
// Main already runs .github/workflows/nix-check.yml, so keep this workflow's
// push path focused on the non-Nix workspace signal.
outputs.nix_validation_required = false;
// Main Docker publishing stays owned by .github/workflows/docker-image.yml.
outputs.docker_validation_required = false;
outputs.workspace_validation_required = true;
} else {
outputs.daemon_tests_required = true;
outputs.web_tests_required = true;
@@ -116,7 +105,7 @@ function createScopePlan(): ScopePlan {
return {
...outputs,
...createRunPlan(outputs, ciMode, eventName),
...createRunPlan(outputs, ciMode),
ui_p0_matrix: JSON.stringify(uiP0CiMatrix),
visual_matrix: JSON.stringify(visualCiMatrix),
};
@@ -136,16 +125,14 @@ function resolveCiMode(eventName: string): CiMode {
function createRunPlan(
outputs: ScopeOutputs,
ciMode: CiMode,
eventName: string,
): Omit<ScopePlan, keyof ScopeOutputs | "ui_p0_matrix" | "visual_matrix"> {
const isFull = ciMode === "full";
const isMainPush = eventName === "push";
return {
ci_mode: ciMode,
run_docker_build: (isFull && !isMainPush) || outputs.docker_validation_required,
run_docker_build: isFull || outputs.docker_validation_required,
run_e2e_vitest: isFull || outputs.web_tests_required || outputs.ui_p0_validation_required,
run_nix_validation: (isFull && !isMainPush) || outputs.nix_validation_required,
run_nix_validation: isFull || outputs.nix_validation_required,
run_playwright_critical: isFull || (outputs.workspace_validation_required && !outputs.ui_p0_validation_required),
run_playwright_visual: isFull || outputs.visual_validation_required,
run_preflight: true,
@@ -363,7 +350,6 @@ function isNixRelevantFile(file: string): boolean {
"flake.nix",
"flake.lock",
".github/workflows/ci.yml",
".github/workflows/nix-check.yml",
".github/workflows/nix-hash-autofix.yml",
"scripts/update-nix-pnpm-deps-hash.ts",
].includes(file) ||
@@ -382,7 +368,6 @@ function isWorkspaceValidationExemptFile(file: string): boolean {
[
"flake.nix",
"flake.lock",
".github/workflows/nix-check.yml",
".github/workflows/landing-page-ci.yml",
".github/workflows/landing-page-staging.yml",
".github/workflows/landing-page-production.yml",
+2 -2
View File
@@ -11,7 +11,7 @@
#
# Or pull a published tag:
#
# docker pull ghcr.io/nexu-io/od:edge
# docker pull ghcr.io/nexu-io/od:latest
#
# Then `docker compose -f tools/pack/docker-compose.yml up -d`.
@@ -22,7 +22,7 @@ services:
# The image tag the operator wants. Spec §15.5 expects multi-arch
# ghcr.io/nexu-io/od:<tag>; the build path above produces
# `open-design/od:dev` locally.
image: ${OD_IMAGE:-ghcr.io/nexu-io/od:edge}
image: ${OD_IMAGE:-ghcr.io/nexu-io/od:latest}
container_name: od
# Spec §15.3 — every dimension the daemon respects in headless