Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/ci-maintenance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: CI Maintenance

# Trusted base-branch code only; never checks out or executes PR content.
on:
pull_request_target:
types: [closed, synchronize]

permissions:
contents: read

concurrency:
group: ci-maintenance-pr-${{ github.event.number }}
cancel-in-progress: true

jobs:
cancel-obsolete-pr:
if: github.event_name == 'pull_request_target' && (github.event.action == 'closed' || github.event.action == 'synchronize')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
contents: read
pull-requests: read
steps:
- uses: actions/github-script@v8
with:
script: |
const repo = context.repo;
const pull_number = context.payload.number;
const readPR = async () => (await github.rest.pulls.get({...repo, pull_number})).data;
const pr = await readPR();
const closed = context.payload.action === 'closed';
if (!closed && context.payload.action !== 'synchronize') return core.info('No obsolete-run cleanup event.');
if (pr.state !== (closed ? 'closed' : 'open')) return core.info('PR state changed; retain qualification.');
if (!closed && context.payload.pull_request?.head.sha !== pr.head.sha) {
return core.info('Synchronize head changed; stop cleanup.');
}
const cutoff = Date.parse(pr.closed_at);
if (!pr.head.repo?.id || !pr.head.ref || !pr.head.sha || (closed && !Number.isFinite(cutoff))) {
throw new Error('PR has no verifiable source repository, branch, head or close time.');
}
// Synchronize supplies the exact replaced head, including for fork
// runs whose Actions response omits pull_requests. Bind both ends
// and the source identity before using this fallback.
const previousHead = !closed && context.payload.after === pr.head.sha &&
context.payload.pull_request?.head.ref === pr.head.ref &&
context.payload.pull_request?.head.repo?.id === pr.head.repo.id
? context.payload.before : null;
const unchanged = current => current.state === pr.state && current.head.sha === pr.head.sha &&
current.head.ref === pr.head.ref && current.head.repo?.id === pr.head.repo.id &&
(!closed || current.closed_at === pr.closed_at);
// Another open PR may use the same source branch against a different base.
const hasOtherOpenPR = async (head = pr.head.label) => (await github.paginate(github.rest.pulls.list,
{...repo, state: 'open', head, per_page: 100}))
.some(p => closed || p.number !== pull_number);
const cancellationScopeUnchanged = async () => {
const current = await readPR();
return unchanged(current) && !(await hasOtherOpenPR(current.head.label));
};
if (await hasOtherOpenPR()) {
return core.info('Source branch has another open PR; retain its qualification.');
}
const runs = await github.paginate(github.rest.actions.listWorkflowRuns,
{...repo, workflow_id: 'python-tests.yml', event: 'pull_request',
branch: pr.head.ref, per_page: 100});
for (const run of runs) {
const created = Date.parse(run.created_at);
// A queued PR event may materialize after close. Only the exact
// closed head can cross that cutoff; recheck its lifecycle below.
if (run.status === 'completed' || run.head_repository?.id !== pr.head.repo.id ||
run.head_branch !== pr.head.ref || !run.head_sha || !Number.isFinite(created) ||
(closed ? created > cutoff && run.head_sha !== pr.head.sha : run.head_sha === pr.head.sha)) continue;
// Missing associations require either the exact closed head or
// the replaced head bound to this verified synchronize event.
if (run.pull_requests?.length
? !run.pull_requests.some(p => p.number === pull_number)
: run.head_sha !== (closed ? pr.head.sha : previousHead)) continue;
if (!(await cancellationScopeUnchanged())) return core.info('PR or source branch changed; stop cleanup.');
const params = {...repo, run_id: run.id};
try {
await github.rest.actions.cancelWorkflowRun(params);
await new Promise(resolve => setTimeout(resolve, 1500));
const {data: remaining} = await github.rest.actions.getWorkflowRun(params);
if (remaining.status !== 'completed') {
if (!(await cancellationScopeUnchanged())) return core.info('PR or source branch changed; stop force cancellation.');
await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel', params);
let terminal = false;
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000));
if ((await github.rest.actions.getWorkflowRun(params)).data.status === 'completed') {
terminal = true;
break;
}
}
if (!terminal) throw new Error(`Cancellation ${run.id} has not completed; inspect its run.`);
}
core.info(`Retired obsolete Python qualification ${run.id}.`);
} catch (error) {
if (error.status !== 409 ||
(await github.rest.actions.getWorkflowRun(params)).data.status !== 'completed') throw error;
core.info(`Qualification ${run.id} completed before cancellation.`);
}
}
143 changes: 55 additions & 88 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ name: Python Tests
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled, closed]
# Trusted base-branch code only; never checks out or executes PR content.
pull_request_target:
types: [closed, synchronize]
# Inert until the main ruleset enables a merge queue; queue runs are full.
merge_group:
workflow_dispatch:
Expand Down Expand Up @@ -43,86 +40,6 @@ env:
LOOPX_USAGE_PING: "0"

jobs:
cancel-obsolete-pr:
if: github.event_name == 'pull_request_target' && (github.event.action == 'closed' || github.event.action == 'synchronize')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
contents: read
pull-requests: read
steps:
- uses: actions/github-script@v8
with:
script: |
const repo = context.repo;
const pull_number = context.payload.number;
const readPR = async () => (await github.rest.pulls.get({...repo, pull_number})).data;
const pr = await readPR();
const closed = context.payload.action === 'closed';
if (!closed && context.payload.action !== 'synchronize') return core.info('No obsolete-run cleanup event.');
if (pr.state !== (closed ? 'closed' : 'open')) return core.info('PR state changed; retain qualification.');
if (!closed && context.payload.pull_request?.head.sha !== pr.head.sha) {
return core.info('Synchronize head changed; stop cleanup.');
}
const cutoff = Date.parse(pr.closed_at);
if (!pr.head.repo?.id || !pr.head.ref || !pr.head.sha || (closed && !Number.isFinite(cutoff))) {
throw new Error('PR has no verifiable source repository, branch, head or close time.');
}
const unchanged = current => current.state === pr.state && current.head.sha === pr.head.sha &&
current.head.ref === pr.head.ref && current.head.repo?.id === pr.head.repo.id &&
(!closed || current.closed_at === pr.closed_at);
// Another open PR may use the same source branch against a different base.
const hasOtherOpenPR = async (head = pr.head.label) => (await github.paginate(github.rest.pulls.list,
{...repo, state: 'open', head, per_page: 100}))
.some(p => closed || p.number !== pull_number);
const cancellationScopeUnchanged = async () => {
const current = await readPR();
return unchanged(current) && !(await hasOtherOpenPR(current.head.label));
};
if (await hasOtherOpenPR()) {
return core.info('Source branch has another open PR; retain its qualification.');
}
const runs = await github.paginate(github.rest.actions.listWorkflowRuns,
{...repo, workflow_id: 'python-tests.yml', event: 'pull_request',
branch: pr.head.ref, per_page: 100});
for (const run of runs) {
const created = Date.parse(run.created_at);
if (run.status === 'completed' || run.head_repository?.id !== pr.head.repo.id ||
run.head_branch !== pr.head.ref || !run.head_sha || !Number.isFinite(created) ||
(closed ? created > cutoff : run.head_sha === pr.head.sha)) continue;
// GitHub can omit PR associations after merge. Only the exact closed head
// is safe in that case; superseded heads remain in their concurrency lane.
if (run.pull_requests?.length
? !run.pull_requests.some(p => p.number === pull_number)
: !closed || run.head_sha !== pr.head.sha) continue;
if (!(await cancellationScopeUnchanged())) return core.info('PR or source branch changed; stop cleanup.');
const params = {...repo, run_id: run.id};
try {
await github.rest.actions.cancelWorkflowRun(params);
await new Promise(resolve => setTimeout(resolve, 1500));
const {data: remaining} = await github.rest.actions.getWorkflowRun(params);
if (remaining.status !== 'completed') {
if (!(await cancellationScopeUnchanged())) return core.info('PR or source branch changed; stop force cancellation.');
await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel', params);
let terminal = false;
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000));
if ((await github.rest.actions.getWorkflowRun(params)).data.status === 'completed') {
terminal = true;
break;
}
}
if (!terminal) throw new Error(`Cancellation ${run.id} has not completed; inspect its run.`);
}
core.info(`Retired obsolete Python qualification ${run.id}.`);
} catch (error) {
if (error.status !== 409 ||
(await github.rest.actions.getWorkflowRun(params)).data.status !== 'completed') throw error;
core.info(`Qualification ${run.id} completed before cancellation.`);
}
}

changes:
# Closed events cancel the old lane without starting another qualification.
if: github.event_name != 'pull_request_target' && (github.event_name != 'pull_request' || github.event.action != 'closed')
Expand All @@ -148,7 +65,9 @@ jobs:
- name: Install workflow contract parser
run: python -m pip install --disable-pip-version-check 'pyyaml>=6,<7'
- name: Validate merge gate semantics
run: python -m unittest discover -s scripts/ci -p 'test_*.py'
run: |
python -m unittest discover -s scripts/ci -p 'test_*.py'
node --test scripts/ci/qualification_summary.test.ts
- name: Classify the exact pull-request change
id: classify
env:
Expand Down Expand Up @@ -453,14 +372,18 @@ jobs:
- name: Reject undefined CSS custom properties
run: node scripts/check-css-custom-properties.mjs dashboard

- name: Measure Dashboard unit and browser interactions
# The coverage merger consumes the unit report from shard 1 only.
- name: Measure Dashboard unit coverage once
if: matrix.shard == 1
run: npm run test:dashboard:coverage

- name: Measure Dashboard browser interactions
env:
LOOPX_PERSONAL_WORKSPACE_SHARD: "${{ matrix.shard }}/3"
LOOPX_DASHBOARD_COVERAGE: "1"
LOOPX_PLAYWRIGHT_PACKAGE: ${{ github.workspace }}/apps/presentation/dashboard/node_modules/playwright
run: |
node --test examples/personal-workspace-browser/shard.test.mjs
npm run test:dashboard:coverage
cd apps/presentation/dashboard
npx playwright install --with-deps chromium --only-shell
node ../../../examples/personal-workspace-browser-smoke.mjs
Expand Down Expand Up @@ -568,8 +491,14 @@ jobs:
npm run test:control-plane

test-shard:
needs: [changes, chat-bundle]
if: needs.changes.outputs.python_tests == 'true'
# Keep known-red PRs out of the long-test queue. Non-PR runs still collect
# complete Python diagnostics after checks finish, even when checks fail.
needs: [changes, chat-bundle, checks]
if: >-
${{ !cancelled() && needs.changes.result == 'success' &&
needs.changes.outputs.python_tests == 'true' &&
needs.chat-bundle.result == 'success' &&
(github.event_name != 'pull_request' || needs.checks.result == 'success') }}
runs-on: ubuntu-latest
# Share six long-test lanes across current PRs and other workflow runs.
# Do not replace another candidate's pending shard or cancel its active head.
Expand Down Expand Up @@ -943,6 +872,9 @@ jobs:
run: python scripts/chat_bundle.py verify --source

merge-gate:
permissions:
contents: read
actions: read
# Open PRs always receive an outcome; closed PRs only cancel their old lane.
if: always() && github.event_name != 'pull_request_target' && (github.event_name != 'pull_request' || github.event.action != 'closed') && (github.event_name != 'push' || !cancelled())
needs: [changes, checks, pytest, node-minimum-compatibility, stage2c-correctness-e2e, windows-powershell, presentation]
Expand All @@ -954,6 +886,41 @@ jobs:
with:
python-version: "3.11"
- name: Reject incomplete or unsuccessful qualification
id: verdict
env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: python scripts/ci/review_gate.py verify
- name: Explain qualification with current-attempt job evidence
if: always()
# Reporting failure cannot change the preceding gate's verdict.
continue-on-error: true
timeout-minutes: 1
uses: actions/github-script@v8
env:
NEEDS_JSON: ${{ toJSON(needs) }}
GATE_RESULT: ${{ steps.verdict.outcome }}
with:
script: |
const {pathToFileURL} = await import('node:url');
const {renderQualificationSummary} = await import(pathToFileURL(
`${process.env.GITHUB_WORKSPACE}/scripts/ci/qualification_summary.ts`).href);
let jobs = null;
try {
jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
...context.repo, run_id: context.runId,
attempt_number: Number(process.env.GITHUB_RUN_ATTEMPT), per_page: 100,
request: {signal: AbortSignal.timeout(10000)},
});
} catch {
core.warning('Job evidence unavailable; the qualification verdict is unchanged.');
}
await core.summary.addRaw(renderQualificationSummary({
repository: `${context.repo.owner}/${context.repo.repo}`,
serverUrl: process.env.GITHUB_SERVER_URL,
runId: context.runId, runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
sourceSha: context.payload.pull_request?.head.sha || context.sha,
checkoutSha: context.sha,
baseSha: context.payload.pull_request?.base.sha || context.payload.merge_group?.base_sha || '',
gateResult: process.env.GATE_RESULT,
needs: JSON.parse(process.env.NEEDS_JSON), jobs,
})).write();
Loading
Loading