diff --git a/.github/workflows/daily-release.yml b/.github/workflows/daily-release.yml index 7b40c78f..72bd5fe6 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 @@ -409,10 +410,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 14c2d358..34076787 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1876,6 +1876,12 @@ inside the header card, so no new `.card[id]` is added and the section-nav contract is unchanged. The user table keeps its fields and POST routes but folds quota, role, private-model and approve/reject controls behind a `Manage` details control, so the default row is a summary rather than a stack of forms. +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 c7ada444..05b15f76 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -2485,6 +2485,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.'))}

` @@ -2499,6 +2506,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 3579d442..82352391 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') @@ -160,13 +161,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,