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
30 changes: 27 additions & 3 deletions .github/workflows/daily-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/819-release-auto-merge.changed.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/lib/releaseStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown> : {}
const pull = entry.pull !== null && typeof entry.pull === 'object' ? entry.pull as Record<string, unknown> : {}
const release = entry.release !== null && typeof entry.release === 'object' ? entry.release as Record<string, unknown> : {}
return {
schema: 1,
version: entry.version,
Expand All @@ -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 : '',
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? `<div class="list"><div class="list-row"><div class="list-main"><strong>${escapeHtml(t('还没有记录发布链', 'No release chain is recorded'))}</strong><small>${escapeHtml(t('这不是错误:发布链只有在管理员于主机上记录过一次校验后才会出现。', 'This is expected, not an error: the chain appears only after an operator records a verification on the host.'))}</small></div></div></div>
<details class="advanced-help"><summary>${escapeHtml(t('在主机上记录发布链', 'Record it on the host'))}</summary><p class="fine">${escapeHtml(t('运行', 'Run'))} <code>node scripts/verify-release.mjs --version X.Y.Z --write &lt;DATA_DIR&gt;/release-status.json</code> ${escapeHtml(t('然后刷新此页。', 'then reload this page.'))}</p></details>`
Expand All @@ -2499,6 +2506,7 @@ export function adminPage(input: {
<div>
<h2>${escapeHtml(t('发布与部署', 'Release & deployment'))}</h2>
${releaseFactsLine}
${releaseTimeline === '' ? '' : `<p class="fine" data-release-timeline>${escapeHtml(releaseTimeline)}</p>`}
</div>
<div class="row">
${releaseStatus === null ? '' : `<span class="badge ${releaseTone}">${escapeHtml(chainLabel)}</span>`}
Expand Down
17 changes: 11 additions & 6 deletions test/dailyRelease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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', () => {
Expand Down
17 changes: 17 additions & 0 deletions test/releaseStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /<section[^>]*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,
Expand Down
Loading