mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
ci: stop duplicate post-merge validation (#4469)
This commit is contained in:
@@ -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,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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user