From b3518a5473f4e534a4c070560862a8d9f09da28b Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 15:56:53 +0800 Subject: [PATCH 1/2] ui: unify the admin release and operations sections Fixes #808 --- changelog.d/808-admin-unify.changed.md | 1 + docs/architecture.md | 9 +++-- public/app.css | 17 ++++----- src/views/pages.ts | 52 ++++++++++++++------------ test/sectionNav.test.ts | 2 +- 5 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 changelog.d/808-admin-unify.changed.md diff --git a/changelog.d/808-admin-unify.changed.md b/changelog.d/808-admin-unify.changed.md new file mode 100644 index 00000000..25ed0393 --- /dev/null +++ b/changelog.d/808-admin-unify.changed.md @@ -0,0 +1 @@ +The admin page now shows one Release & deployment summary — one running version, one status badge, one GitHub Release link, only problem links and one running-instance row, with the full chain behind a details disclosure — and Operations is a compact list instead of a grid of large link tiles. diff --git a/docs/architecture.md b/docs/architecture.md index 505c6699..19d3f1fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1716,9 +1716,12 @@ The `/admin` card renders the snapshot the command writes to GitHub, and a missing or unparseable snapshot renders "no release chain is recorded" rather than an empty card. The snapshot carries the tag target and the Release URL as fields (`targetSha`, `releaseUrl`); the target is never re-read -out of a link's detail text. The card renders the artifact links, omits links the -snapshot marked `skipped` (a deleted release branch, no local image) and the -stored `healthz` probe, and adds one live `Running instance` row. That row +out of a link's detail text. The card is one "Release & deployment" summary: the +running version, CHANGELOG section and pending fragments share one line; the +header carries one status badge and one GitHub Release link. It renders one live +`Running instance` row and only the artifact links the snapshot marked `missing` +or `mismatch`; the `skipped` provenance links and the stored `healthz` probe are +omitted, and the full chain sits behind a details disclosure. That row compares the version in `package.json` and the commit stamp this process booted with against the recorded tag target, so an operator can see at a glance whether the instance they are looking at is the release the chain names. The header says diff --git a/public/app.css b/public/app.css index 9338c641..1d9a1e5c 100644 --- a/public/app.css +++ b/public/app.css @@ -1259,13 +1259,10 @@ html:lang(zh) .stat strong { letter-spacing: 0; line-height: 1.15; } } /* Admin module tiles: the operations pages are one entry in the admin hub, not a sentence of inline links. A tile is a link and keeps the 44px target. */ -.admin-modules { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-3); margin-top: var(--space-3); } -.admin-module { - display: flex; flex-direction: column; gap: 6px; min-height: var(--touch); - padding: var(--space-4); border: 1px solid var(--line); border-radius: var(--radius-md); - background: var(--surface-sunken); color: var(--text); text-decoration: none; -} -.admin-module:hover { background: var(--surface-raised); border-color: var(--accent); text-decoration: none; } -.admin-module strong { font-size: var(--text-md); } -.admin-module small { color: var(--muted); font-size: var(--text-sm); line-height: 1.5; } -.admin-module-go { margin-top: auto; color: var(--accent); font-size: var(--text-sm); font-weight: 650; } +.admin-modules { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; margin-top: var(--space-2); border-top: 1px solid var(--line); } +.admin-module { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; column-gap: 16px; align-items: center; padding: 10px 0; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; } +.admin-module:hover { background: transparent; border-color: var(--line); text-decoration: none; } +.admin-module:hover strong { color: var(--accent); } +.admin-module strong { grid-column: 1; font-size: var(--text-md); } +.admin-module small { grid-column: 1; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; } +.admin-module-go { grid-column: 2; grid-row: 1 / span 2; margin: 0; color: var(--accent); font-size: var(--text-sm); font-weight: 650; } diff --git a/src/views/pages.ts b/src/views/pages.ts index 2020f48c..ae8532e0 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -2267,11 +2267,7 @@ export function adminPage(input: { // the card names it as the running version and never claims a commit. A file // that is not in this build says so; a zero fragment count is a real fact. const absent = t('此构建中不可用', 'not available in this build') - const releaseFacts = `
-
${escapeHtml(t('运行版本', 'Running'))}${release.version === null ? escapeHtml(absent) : `v${escapeHtml(release.version)} · ${escapeHtml(t('来自 package.json', 'from package.json'))}`}
-
${escapeHtml(t('最新 CHANGELOG 段落', 'Newest CHANGELOG section'))}${release.changelogVersion === null || release.releasedAt === null ? escapeHtml(absent) : `${escapeHtml(release.changelogVersion)} · ${escapeHtml(release.releasedAt)}`}
-
${escapeHtml(t('待发布片段', 'Pending fragments'))}${release.fragments === null ? escapeHtml(absent) : release.fragments === 0 ? escapeHtml(t('没有待发布片段', 'nothing pending')) : escapeHtml(t(`${release.fragments} 个待发布片段`, `${release.fragments} pending fragment${release.fragments === 1 ? '' : 's'}`))}
-
` + const releaseFactsLine = `

${release.version === null ? escapeHtml(absent) : `v${escapeHtml(release.version)}`}${release.changelogVersion === null || release.releasedAt === null ? ` · ${escapeHtml(absent)}` : ` · CHANGELOG ${escapeHtml(release.changelogVersion)} · ${escapeHtml(release.releasedAt)}`}${release.fragments === null ? ` · ${escapeHtml(absent)}` : ` · ${release.fragments === 0 ? escapeHtml(t('没有待发布片段', 'nothing pending')) : escapeHtml(t(`${release.fragments} 个待发布片段`, `${release.fragments} pending fragment${release.fragments === 1 ? '' : 's'}`))}`}

` // Issue #280: the chain is assembled by `node scripts/verify-release.mjs` and // read from `${DATA_DIR}/release-status.json`; the page renders what that run // recorded and never calls GitHub itself. The running row is the only live @@ -2299,10 +2295,6 @@ export function adminPage(input: { const pair = labels[id] ?? [id, id] return t(pair[0]!, pair[1]!) } - // The live row is the one that answers "is this process the tag?"; the stored - // `healthz` probe is a snapshot from `verify-release`, so rendering both - // repeats the same comparison at two different times. Skipped links are - // provenance (a deleted release branch, no local image), not actions. const running = releaseStatus === null ? null : compareRunning(releaseStatus, { version: release.version ?? '', commit: config.build.commit }) @@ -2318,19 +2310,33 @@ export function adminPage(input: { : running?.state === 'ok' ? t('已发布并运行', 'released and running') : t('已发布,运行实例不一致', 'published; running differs') - const chainRows = releaseStatus === null - ? `
${escapeHtml(t('还没有记录发布链', 'No release chain is recorded'))}${escapeHtml(t('这不是错误:发布链只有在管理员于主机上记录过一次校验后才会出现。', 'This is expected, not an error: the chain appears only after an operator records a verification on the host.'))}
+ const linkRow = (entry: { id: string; state: string; detail: string }): string => + `
${escapeHtml(linkLabel(entry.id))}${escapeHtml(entry.detail)}
${stateBadge(entry.state)}
` + const problemLinks = visibleLinks.filter((entry) => entry.state === 'missing' || entry.state === 'mismatch') + const releaseProblemRows = problemLinks.map(linkRow).join('') + const fullRows = visibleLinks.map(linkRow).join('') + const releaseTone = releaseStatus !== null && releaseArtifactsOk && running?.state === 'ok' ? 'good' : 'bad' + const releaseDetails = releaseStatus === null + ? `
${escapeHtml(t('还没有记录发布链', 'No release chain is recorded'))}${escapeHtml(t('这不是错误:发布链只有在管理员于主机上记录过一次校验后才会出现。', 'This is expected, not an error: the chain appears only after an operator records a verification on the host.'))}
${escapeHtml(t('在主机上记录发布链', 'Record it on the host'))}

${escapeHtml(t('运行', 'Run'))} node scripts/verify-release.mjs --version X.Y.Z --write <DATA_DIR>/release-status.json ${escapeHtml(t('然后刷新此页。', 'then reload this page.'))}

` - : `${visibleLinks - .map( - (entry) => `
${escapeHtml(linkLabel(entry.id))}${escapeHtml(entry.detail)}
${stateBadge(entry.state)}
`, - ) - .join('')}${running === null ? '' : `
${escapeHtml(t('运行实例', 'Running instance'))}${escapeHtml(running.detail)}
${stateBadge(running.state)}
`}` + : `
+
${escapeHtml(t('运行实例', 'Running instance'))}${escapeHtml(running?.detail ?? '')}
${stateBadge(running?.state ?? 'unknown')}
+ ${releaseProblemRows} + ${problemLinks.length === 0 ? `
${escapeHtml(t('发布链', 'Release chain'))}${escapeHtml(t('所有发布产物一致;完整证据可展开。', 'All release artifacts agree; open the evidence for the full chain.'))}
${stateBadge('ok')}
` : ''} +
+
${escapeHtml(t('发布链证据', 'Release chain evidence'))}${releaseStatus.checkedAt === '' ? '' : ` · ${escapeHtml(t(`检查于 ${releaseStatus.checkedAt}`, `checked ${releaseStatus.checkedAt}`))}`}
${fullRows}
` const releaseCard = `
-

${escapeHtml(t('发布', 'Release'))}

- ${releaseFacts} -

${escapeHtml(t('发布链', 'Release chain'))}${releaseStatus === null ? '' : ` v${escapeHtml(releaseStatus.version)} · ${escapeHtml(chainLabel)}${releaseStatus.checkedAt === '' ? '' : ` · ${escapeHtml(t(`检查于 ${releaseStatus.checkedAt}`, `checked ${releaseStatus.checkedAt}`))}`}${releaseStatus.releaseUrl === '' ? '' : ` · ${escapeHtml(t('GitHub Release', 'GitHub Release'))}`}`}

-
${chainRows}
+
+
+

${escapeHtml(t('发布与部署', 'Release & deployment'))}

+ ${releaseFactsLine} +
+
+ ${releaseStatus === null ? '' : `${escapeHtml(chainLabel)}`} + ${releaseStatus === null || releaseStatus.releaseUrl === '' ? '' : `${escapeHtml(t('GitHub Release', 'GitHub Release'))}`} +
+
+ ${releaseDetails}
` // The console can only show what the writer leaves on disk: a copy and its @@ -2447,6 +2453,7 @@ export function adminPage(input: { ${showAudit ? `${escapeHtml(t('审计', 'Audit'))}` : ''} ${escapeHtml(t('发布', 'Release'))} ${escapeHtml(t('备份', 'Backups'))} + ${escapeHtml(t('运维', 'Operations'))}

${escapeHtml(t('待审批', 'Pending'))} ${pending.length}

@@ -2468,9 +2475,8 @@ export function adminPage(input: {
` : ''} ${releaseCard} ${backupCard} -
-

${escapeHtml(t('运维工具', 'Operations'))}

-

${escapeHtml(t('这些是独立的运维页面;下面的卡片把它们放在管理页的同一个入口里。', 'These are separate operations pages; the cards below collect them in one place on the admin page.'))}

+
+

${escapeHtml(t('运维工具', 'Operations'))}

${escapeHtml(t('独立运维页面', 'separate operator pages'))}
${superadmin ? `${escapeHtml(t('产品指标', 'Product metrics'))}${escapeHtml(t('用户、任务、成本、失败分类和告警。', 'Users, jobs, cost, failure breakdown and alerts.'))}${escapeHtml(t('打开', 'Open'))} → ${escapeHtml(t('Webhook deliveries', 'Webhook deliveries'))}${escapeHtml(t('出站通知投递状态、重试和详情。', 'Outbound notification delivery state, retries and detail.'))}${escapeHtml(t('打开', 'Open'))} →` : ''} diff --git a/test/sectionNav.test.ts b/test/sectionNav.test.ts index a593a164..7a79da98 100644 --- a/test/sectionNav.test.ts +++ b/test/sectionNav.test.ts @@ -133,7 +133,7 @@ test('every card with an id is named by exactly one chip, in document order', () test('every nav chip resolves to exactly one section in the same rendered page', () => { const pages = [ { picker: 'settings' as const, ids: ['account', 'model-providers', 'notifications', 'tokens', 'sessions'] }, - { picker: 'admin' as const, ids: ['pending', 'users', 'jobs', 'audit', 'release', 'backups'] }, + { picker: 'admin' as const, ids: ['pending', 'users', 'jobs', 'audit', 'release', 'backups', 'operations'] }, ] for (const page of pages) { const html = render(page.picker) From 161a26f3d283ae8b55e9361a91040822bcf714d4 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 16:27:17 +0800 Subject: [PATCH 2/2] feat: auto-merge the mechanical release PR and show its timeline Fixes #819 --- .github/workflows/daily-release.yml | 30 +++++++++++++++++-- changelog.d/819-release-auto-merge.changed.md | 1 + docs/architecture.md | 7 +++++ src/lib/releaseStatus.ts | 12 ++++++++ src/views/pages.ts | 8 +++++ test/dailyRelease.test.ts | 17 +++++++---- test/releaseStatus.test.ts | 17 +++++++++++ 7 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 changelog.d/819-release-auto-merge.changed.md diff --git a/.github/workflows/daily-release.yml b/.github/workflows/daily-release.yml index 553e2765..956ca05e 100644 --- a/.github/workflows/daily-release.yml +++ b/.github/workflows/daily-release.yml @@ -16,8 +16,9 @@ name: Daily minor release # automatic token and opens exactly one release pull request with the # team token (`RELEASE_OPEN_TOKEN`). Without that secret the night is # red, pushes nothing and names the missing secret; it never reports -# a green blocked night after pushing a branch. It never merges or -# publishes a pull request. A push to `main` is eligible only when +# a green blocked night after pushing a branch. It enables GitHub +# auto-merge on the mechanical release PR; it never creates or +# publishes a pull request itself. A push to `main` is eligible only when # the paths filter below matched a release-shaped change, so an # ordinary feature merge no longer starts a full plan job # (`fetch-depth: 0`, several API calls) just to report @@ -402,10 +403,33 @@ jobs: else DETAIL="release-open refused: $(jq -r '.reason // "no reason recorded"' "$RUNNER_TEMP/open.json" 2>/dev/null || echo 'unreadable output')" fi - printf 'state=%s\ndetail=%s\n' "$STATE" "$DETAIL" >> "$GITHUB_OUTPUT" + PR="$(jq -r '.pr // ""' "$RUNNER_TEMP/open.json" 2>/dev/null || true)" + printf 'state=%s\ndetail=%s\npr=%s\n' "$STATE" "$DETAIL" "$PR" >> "$GITHUB_OUTPUT" echo "- release pull request: **${STATE}** — ${DETAIL}" >> "$GITHUB_STEP_SUMMARY" exit "$CODE" + - name: Enable auto-merge on the release pull request + # Only the mechanical release PR is auto-merged. The PR is opened by the + # team token, and the required checks (ci-ok, review-gate, pr-lint) still + # decide when GitHub may squash it; this step only opts it in. + if: ${{ inputs.dry_run != true && (steps.open.outputs.state == 'opened' || steps.open.outputs.state == 'reused') }} + env: + GH_TOKEN: ${{ secrets.RELEASE_OPEN_TOKEN }} + PR: ${{ steps.open.outputs.pr }} + PUSHED: ${{ steps.push.outputs.pushed }} + run: | + set -euo pipefail + if [ -z "$PR" ] || [ -z "$PUSHED" ]; then + echo "::error::the release pull request or its head SHA is missing; refusing to enable auto-merge" + exit 1 + fi + if gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest --jq '.autoMergeRequest != null' | grep -q true; then + echo "auto-merge already enabled on #${PR}" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + gh pr merge "$PR" --auto --squash --match-head-commit "$PUSHED" --repo "$GITHUB_REPOSITORY" + echo "auto-merge enabled on #${PR} at ${PUSHED}" >> "$GITHUB_STEP_SUMMARY" + - name: Comment on #80 # `always()` so a refused opener still says why on #80: the open state is # the record, and a red night must not be silent about it. diff --git a/changelog.d/819-release-auto-merge.changed.md b/changelog.d/819-release-auto-merge.changed.md new file mode 100644 index 00000000..8b1f1b14 --- /dev/null +++ b/changelog.d/819-release-auto-merge.changed.md @@ -0,0 +1 @@ +The mechanical daily release pull request now opts into GitHub auto-merge after it is opened or reused, so it squashes once ci-ok, review-gate and pr-lint are green instead of waiting for a manual merge; the release card also shows the plan → merge → publish timeline. diff --git a/docs/architecture.md b/docs/architecture.md index 19d3f1fb..f98b712c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1731,6 +1731,13 @@ incomplete`. A published GitHub Release is therefore never labelled "not released" just because the host runs a local build. A snapshot that claims `ok` with no links, or that contradicts its own problems, is refused as unparseable — a green header over an empty table is the one shape the card must never render. + +The mechanical release pull request is opted into GitHub auto-merge after it is +opened or reused; `ci-ok`, `review-gate` and `pr-lint` still decide when it may +squash, and `--match-head-commit` pins the tip. The release card also renders a +compact `plan → merge → publish` timeline from the snapshot's `plan.plannedAt`, +`pull.mergedAt` and `release.publishedAt`, so a late release can be attributed to +the scheduler, the checks or the merge wait. ### Backup surface The other half of "your data is safe" is a fact the operator can see without diff --git a/src/lib/releaseStatus.ts b/src/lib/releaseStatus.ts index b85ef8f2..c227164e 100644 --- a/src/lib/releaseStatus.ts +++ b/src/lib/releaseStatus.ts @@ -29,6 +29,12 @@ export interface ReleaseStatusSnapshot { targetSha: string /** The GitHub Release url, empty when the chain has no Release. */ releaseUrl: string + /** ISO instant the release plan was written, empty when unreadable. */ + planPlannedAt?: string + /** ISO instant the release pull request merged, empty when unreadable. */ + mergedAt?: string + /** ISO instant the GitHub Release was published, empty when unreadable. */ + publishedAt?: string } export interface RunningInstance { @@ -75,6 +81,9 @@ export function parseReleaseStatus(raw: unknown): ReleaseStatusSnapshot | null { if (ok && (entry.links.length === 0 || problems.length > 0)) return null const targetSha = typeof entry.targetSha === 'string' && SHA.test(entry.targetSha) ? entry.targetSha : '' const releaseUrl = typeof entry.releaseUrl === 'string' && /^https?:\/\//.test(entry.releaseUrl) ? entry.releaseUrl : '' + const plan = entry.plan !== null && typeof entry.plan === 'object' ? entry.plan as Record : {} + const pull = entry.pull !== null && typeof entry.pull === 'object' ? entry.pull as Record : {} + const release = entry.release !== null && typeof entry.release === 'object' ? entry.release as Record : {} return { schema: 1, version: entry.version, @@ -85,6 +94,9 @@ export function parseReleaseStatus(raw: unknown): ReleaseStatusSnapshot | null { problems, targetSha, releaseUrl, + planPlannedAt: typeof plan.plannedAt === 'string' ? plan.plannedAt : '', + mergedAt: typeof pull.mergedAt === 'string' ? pull.mergedAt : '', + publishedAt: typeof release.publishedAt === 'string' ? release.publishedAt : '', } } diff --git a/src/views/pages.ts b/src/views/pages.ts index ae8532e0..e64bce9a 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -2316,6 +2316,13 @@ export function adminPage(input: { const releaseProblemRows = problemLinks.map(linkRow).join('') const fullRows = visibleLinks.map(linkRow).join('') const releaseTone = releaseStatus !== null && releaseArtifactsOk && running?.state === 'ok' ? 'good' : 'bad' + const releaseTimeline = releaseStatus === null + ? '' + : [ + (releaseStatus.planPlannedAt ?? '') === '' ? '' : `${t('计划', 'plan')} ${humanTimeShort(releaseStatus.planPlannedAt ?? '')}`, + (releaseStatus.mergedAt ?? '') === '' ? '' : `${t('合并', 'merge')} ${humanTimeShort(releaseStatus.mergedAt ?? '')}`, + (releaseStatus.publishedAt ?? '') === '' ? '' : `${t('发布', 'publish')} ${humanTimeShort(releaseStatus.publishedAt ?? '')}`, + ].filter((part): part is string => part !== '').join(' → ') const releaseDetails = releaseStatus === null ? `
${escapeHtml(t('还没有记录发布链', 'No release chain is recorded'))}${escapeHtml(t('这不是错误:发布链只有在管理员于主机上记录过一次校验后才会出现。', 'This is expected, not an error: the chain appears only after an operator records a verification on the host.'))}
${escapeHtml(t('在主机上记录发布链', 'Record it on the host'))}

${escapeHtml(t('运行', 'Run'))} node scripts/verify-release.mjs --version X.Y.Z --write <DATA_DIR>/release-status.json ${escapeHtml(t('然后刷新此页。', 'then reload this page.'))}

` @@ -2330,6 +2337,7 @@ export function adminPage(input: {

${escapeHtml(t('发布与部署', 'Release & deployment'))}

${releaseFactsLine} + ${releaseTimeline === '' ? '' : `

${escapeHtml(releaseTimeline)}

`}
${releaseStatus === null ? '' : `${escapeHtml(chainLabel)}`} diff --git a/test/dailyRelease.test.ts b/test/dailyRelease.test.ts index f1efe635..4be4dcd1 100644 --- a/test/dailyRelease.test.ts +++ b/test/dailyRelease.test.ts @@ -4,10 +4,11 @@ import { execFileSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' // The contract of issue #80, as a structural test: the file may carry no stored -// secret, no file-level permissions block, no pull-request or release command and -// no local tag command, and the tag write exists only as the two API calls. Every -// assertion here fails on the pre-#80 file, where `RELEASE_TOKEN` gated the run, -// the permissions block was file-level, and the tag came from +// secret, no file-level permissions block, no pull-request creation or release +// command and no local tag command. Auto-merge is the one deliberate exception: +// it is enabled only on the mechanical release PR with a pinned head commit. +// Every assertion here fails on the pre-#80 file, where `RELEASE_TOKEN` gated the +// run, the permissions block was file-level, and the tag came from // `git tag`/`git push origin "$TAG"`. const daily = readFileSync('.github/workflows/daily-release.yml', 'utf8') @@ -150,13 +151,17 @@ test('only the trigger can let a run push, and a manual dry run never does', () assert.match(pushStep, /if: \$\{\{ inputs\.dry_run != true && steps\.trigger\.outputs\.plan == 'true' && steps\.opener\.outputs\.ready == 'true' \}\}/) }) -test('the file never creates, merges or releases a pull request with gh, and never creates a tag with a git command', () => { - for (const forbidden of ['gh pr create', 'gh pr merge', 'gh release', 'git tag', 'git push origin "$TAG"', 'git push --tags', '--admin']) { +test('the file never creates or releases a pull request with gh, and auto-merge is pinned to the mechanical PR', () => { + for (const forbidden of ['gh pr create', 'gh release', 'git tag', 'git push origin "$TAG"', 'git push --tags', '--admin']) { assert.equal(daily.includes(forbidden), false, `"${forbidden}" must not appear`) } const pushes = [...daily.matchAll(/git push /g)] assert.equal(pushes.length, 1, 'exactly one git push, of the release branch') assert.ok(daily.includes('git push origin "$BRANCH"'), 'that push is the release branch') + assert.match(daily, /gh pr merge "\$PR" --auto --squash --match-head-commit "\$PUSHED"/, 'auto-merge names the PR and pins its head commit') + const step = between(' - name: Enable auto-merge on the release pull request', ' - name: Comment on #80') + assert.match(step, /steps\.open\.outputs\.state == 'opened' \|\| steps\.open\.outputs\.state == 'reused'/) + assert.doesNotMatch(step, /--admin/) }) test('the tag write is the two API calls on the merge commit and nothing else', () => { diff --git a/test/releaseStatus.test.ts b/test/releaseStatus.test.ts index 6fccab77..ee2eb362 100644 --- a/test/releaseStatus.test.ts +++ b/test/releaseStatus.test.ts @@ -283,6 +283,23 @@ test('the admin page renders the recorded chain, its digest and its Release URL, assert.doesNotMatch(page, /gh[pousr]_[A-Za-z0-9]{20,}/) }) +test('the admin page shows the plan, merge and publish timeline when the snapshot carries it', async () => { + const { evaluateReleaseStatus } = await import('../scripts/lib/release-status.mjs') + const version = String(JSON.parse(readFileSync('package.json', 'utf8')).version) + const status = evaluateReleaseStatus(chain({ version, packageVersion: version, healthz: null, baseUrl: '' })) + const page = await adminPage({ + ...status, + plan: { plannedAt: '2026-11-14T19:24:31.000Z' }, + pull: { mergedAt: '2026-11-14T20:17:17.000Z' }, + release: { publishedAt: '2026-11-14T20:19:38.000Z' }, + }) + const release = /]*id="release"[\s\S]*?<\/section>/.exec(page)?.[0] ?? '' + assert.match(release, /data-release-timeline/) + assert.match(release, /19:24/) + assert.match(release, /20:17/) + assert.match(release, /20:19/) +}) + test('the admin page does not turn an unreadable Release URL into a link', async () => { const body = { schema: 1,