From 6c6f53491f72357f97e06ce40e5ace87a61a1cde Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 09:02:17 +0300 Subject: [PATCH] fix(release): bind beta bookkeeping to candidate (#547) Issue: #547 User-Visible: no --- .github/workflows/publish-prerelease.yml | 142 ++++++++++------ AGENTS.md | 14 +- PROCESS.md | 15 +- docs/ARCHITECTURE.md | 6 +- docs/DEVELOPMENT.md | 33 ++-- scripts/mutation-gate.mjs | 23 +++ scripts/release-assets.mjs | 8 +- scripts/release-bookkeeping.mjs | 102 +++++++++++ scripts/release-membership.mjs | 207 +++++++++++++++++++++++ scripts/release-prerelease.mjs | 84 +++++---- test/release-assets.test.mjs | 18 +- test/release-bookkeeping.test.mjs | 105 ++++++++++++ test/release-contract.test.mjs | 26 ++- test/release-membership.test.mjs | 57 +++++++ 14 files changed, 722 insertions(+), 118 deletions(-) create mode 100644 scripts/release-bookkeeping.mjs create mode 100644 scripts/release-membership.mjs create mode 100644 test/release-bookkeeping.test.mjs create mode 100644 test/release-membership.test.mjs diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 7c43d62d..5f15fcca 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -12,6 +12,7 @@ on: permissions: contents: write actions: read + issues: read concurrency: group: publish-prerelease-${{ inputs.tag }} @@ -31,7 +32,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-node@v7 with: { node-version: 22 } - - name: Pin the current dev candidate + - name: Pin the dev candidate or the existing annotated tag id: candidate env: TAG: ${{ inputs.tag }} @@ -42,12 +43,23 @@ jobs: echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME" exit 1 } - SHA=$(git rev-parse HEAD) - git fetch origin dev - test "$(git rev-parse origin/dev)" = "$SHA" || { - echo "::error::The dispatched SHA is no longer the origin/dev tip" - exit 1 - } + DISPATCHED_SHA=$(git rev-parse HEAD) + git fetch --force origin dev --tags + REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") + if [ -n "$REMOTE" ]; then + SHA=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}') + test -n "$SHA" || { + echo "::error::Existing remote tag $TAG is not annotated" + exit 1 + } + else + SHA=$DISPATCHED_SHA + test "$(git rev-parse origin/dev)" = "$SHA" || { + echo "::error::The dispatched SHA is no longer the origin/dev tip" + exit 1 + } + fi + git checkout --detach "$SHA" echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Verify version, changelogs and bilingual release notes @@ -77,6 +89,35 @@ jobs: REPO: ${{ github.repository }} SHA: ${{ steps.candidate.outputs.sha }} run: node scripts/release-gate.mjs "$SHA" + - name: Bind issue membership to the exact candidate + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.candidate.outputs.tag }} + SHA: ${{ steps.candidate.outputs.sha }} + run: | + set -euo pipefail + mkdir -p release-membership + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --dir release-membership --pattern RELEASE-MEMBERSHIP.json --clobber || true + fi + if [ -s release-membership/RELEASE-MEMBERSHIP.json ]; then + node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \ + --input=release-membership/RELEASE-MEMBERSHIP.json + else + ISSUES=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --label S8-merged --limit 1000 --json number --jq 'map(.number)|join(",")') + node scripts/release-membership.mjs create --tag="$TAG" --candidate="$SHA" \ + --issues="$ISSUES" --allow-unmatched \ + --output=release-membership/RELEASE-MEMBERSHIP.json + fi + - name: Preserve candidate membership for publication + uses: actions/upload-artifact@v7 + with: + name: release-membership + path: release-membership/RELEASE-MEMBERSHIP.json + if-no-files-found: error + retention-days: 7 publish: name: Публикация тега и релиза @@ -92,6 +133,10 @@ jobs: fetch-depth: 0 - uses: actions/setup-node@v7 with: { node-version: 22 } + - uses: actions/download-artifact@v7 + with: + name: release-membership + path: release-assets - name: Build and verify both release assets before publication env: TAG: ${{ needs.gate.outputs.tag }} @@ -115,7 +160,9 @@ jobs: test -s houseplan.zip mkdir -p release-assets cp dist/houseplan-card.js houseplan.zip release-assets/ - node scripts/release-assets.mjs sums release-assets + node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \ + --input=release-assets/RELEASE-MEMBERSHIP.json + node scripts/release-assets.mjs sums release-assets --include-membership - name: Create or verify the annotated tag env: TAG: ${{ needs.gate.outputs.tag }} @@ -154,8 +201,23 @@ jobs: fi WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT" + if [ "$WAS_DRAFT" = "false" ]; then + mkdir -p existing-public + if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir existing-public \ + --pattern houseplan-card.js --pattern houseplan.zip \ + --pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber \ + && diff -u release-assets/SHA256SUMS existing-public/SHA256SUMS \ + && node scripts/release-assets.mjs check existing-public release-assets/SHA256SUMS \ + && node scripts/release-membership.mjs verify --tag="$TAG" --candidate="${{ needs.gate.outputs.sha }}" \ + --input=existing-public/RELEASE-MEMBERSHIP.json; then + echo "release is already public and byte-identical; publication skipped" + exit 0 + fi + echo "existing public assets need recovery; verified files will be uploaded again" + fi gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \ - release-assets/SHA256SUMS --repo "$GITHUB_REPOSITORY" --clobber + release-assets/RELEASE-MEMBERSHIP.json release-assets/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" --clobber RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ --json tagName,isDraft,isPrerelease,assets,url) export RELEASE_JSON TAG @@ -163,7 +225,7 @@ jobs: const release = JSON.parse(process.env.RELEASE_JSON); if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch'); const assets = new Map(release.assets.map((asset) => [asset.name, asset])); - for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) { + for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) { if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`); } NODE @@ -185,16 +247,19 @@ jobs: if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease) throw new Error('release is not a public prerelease for the requested tag'); const assets = new Map(release.assets.map((asset) => [asset.name, asset])); - for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) { + for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) { if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`); } NODE # #540: публичные байты — ровно те, что собраны и проверены выше. mkdir -p public gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \ - --pattern houseplan-card.js --pattern houseplan.zip --pattern SHA256SUMS --clobber + --pattern houseplan-card.js --pattern houseplan.zip \ + --pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber diff -u release-assets/SHA256SUMS public/SHA256SUMS node scripts/release-assets.mjs check public release-assets/SHA256SUMS + node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \ + --input=public/RELEASE-MEMBERSHIP.json test "$(git rev-list -n 1 "$TAG")" = "$SHA" URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url") echo "url=$URL" >> "$GITHUB_OUTPUT" @@ -219,26 +284,31 @@ jobs: ); } - # PROCESS.md 10.2 item 10: closing issues and stripping status labels happens - # because a beta was published, not because someone remembered to do it. The - # manual step was skipped twice, and both times it broke the invariant that a - # closed issue carries no status label — the one thing `verify` relies on. - # - # A manual step after a successful release is the worst kind: by the time it is - # due, the work already looks finished, which is exactly why it gets forgotten. + # #547: bookkeeping is driven by the immutable candidate manifest, not by the + # mutable S8 queue. It also runs on a verified retry of an already-public beta. close-merged: name: Закрытие вошедших issue needs: [gate, publish] - if: ${{ needs.publish.outputs.newly_published == 'true' }} runs-on: ubuntu-latest permissions: contents: read + actions: read # Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do # not start workflows, so removing the label cannot wake the review # pipeline. A PAT here would build a cascade out of a bookkeeping step. issues: write steps: - - name: Close the S8-merged queue and strip status labels + - uses: actions/checkout@v7 + with: + ref: ${{ needs.gate.outputs.sha }} + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: { node-version: 22 } + - uses: actions/download-artifact@v7 + with: + name: release-membership + path: release-membership + - name: Finish only the issues proven in the candidate manifest env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -246,33 +316,9 @@ jobs: URL: ${{ needs.publish.outputs.url }} run: | set -euo pipefail - # Only the owner's issues take part in the process; issues filed by - # anyone else never carry status labels and are not ours to close. - numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \ - --author Matysh --limit 100 --json number --jq '.[].number') - if [ -z "$numbers" ]; then - echo "the S8-merged queue is empty, nothing to close" - else - for n in $numbers; do - gh issue comment "$n" --repo "$REPO" \ - --body "Выпущено в \`$TAG\` · [релиз]($URL)" - # Label first, then close. If the run dies between the two steps an - # open issue without a status is visible and fixable in the flow; - # the reverse order would recreate the exact breakage this job is - # here to prevent. - gh issue edit "$n" --repo "$REPO" --remove-label S8-merged - gh issue close "$n" --repo "$REPO" --reason completed - echo "closed #$n" - done - fi - # Targeted at the defect that actually recurs, not at the invariant in - # general: no closed issue may still carry S8-merged. - leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \ - --limit 100 --json number --jq 'length') - test "$leftover" = "0" || { - echo "::error::$leftover closed issues still carry S8-merged" - exit 1 - } + node scripts/release-bookkeeping.mjs --repo="$REPO" --tag="$TAG" \ + --candidate="${{ needs.gate.outputs.sha }}" --url="$URL" \ + --membership=release-membership/RELEASE-MEMBERSHIP.json announce: name: Комментарий о публикации diff --git a/AGENTS.md b/AGENTS.md index 6b122b70..8ef4bb69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -484,10 +484,11 @@ screenshots `sourceFingerprint` against current `src/**`, which is exactly what went red after the #113 merge. A stable release additionally waits for Full Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`, dispatched on the candidate SHA by `release.yml`, #514/#540); betas and the -development cycle never run E2E. Installable assets (`houseplan.zip`, -`houseplan-card.js`, `SHA256SUMS`) reach the public release only from -`release.yml` after those gates; a release published by hand is turned back into -a draft first (#540). +development cycle never run E2E. Stable installable assets (`houseplan.zip`, +`houseplan-card.js` and their `SHA256SUMS`) reach the public stable release only +from `release.yml` after those gates; a release published by hand is turned back +into a draft first (#540). The prerelease publisher additionally ships a +candidate-bound `RELEASE-MEMBERSHIP.json` covered by the same passport (#547). **"Verified" without a named command and its result is not evidence.** @@ -547,4 +548,7 @@ the work itself and follows the ordinary rules, trailers included. Issues are closed in a batch when a beta ships, not when implementation ends: that way a bug found in the beta returns to the same task, and the beta announcement can -list what went in. Status labels are stripped as the issues close. +list what went in. The batch is immutable candidate membership proven by Git +trailers, never the mutable S8 queue at close time; authorship does not change +membership. Status labels are stripped as the issues close, and retries resume the +same manifest without duplicating the release comment (#547). diff --git a/PROCESS.md b/PROCESS.md index 32424803..634ad2dc 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -878,10 +878,17 @@ post-beta коммит остаётся в проверке и по закрыт Не реализовано и остаётся долгом: 9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue - зелёный вердикт код-ревью; -10. закрытие issue и снятие статусных меток при публикации беты делаются руками — - `node process-labels/apply.mjs cleanup --apply`, а не `publish-prerelease.yml`. - Пропуск этого шага уже ломал инвариант «закрытый issue без статусной метки». + зелёный вердикт код-ревью. + +Закрытие вошедших issue автоматизировано (#120, #547). При старте беты текущая +очередь S8 служит только списком кандидатов: `RELEASE-MEMBERSHIP.json` оставляет +из неё лишь номера с доказанным `Issue: #NN` в Git-диапазоне зафиксированного +SHA. Manifest публикуется и входит в `SHA256SUMS`; свежая очередь S8 после +публикации не перечитывается. Общий для workflow и локальной команды bookkeeping +идемпотентно добавляет один маркированный release-комментарий, снимает все +статусные метки и закрывает issue. Поэтому повтор после сбоя продолжает manifest, +даже если метка уже снята или release уже public; автор issue на membership не +влияет. ### 10.3 Страховка и разбор diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a8ea328..9b43ec5a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -124,7 +124,11 @@ version authorities, both changelogs need a dated section, and the canonical bilingual `docs/RELEASE-NOTES.md` must link to immutable tagged changelogs. A public release is assembled as a draft, receives and verifies `houseplan-card.js` plus `houseplan.zip`, and becomes visible only after the -exact candidate SHA has a green Validate. Existing release-event workflows are +exact candidate SHA has a green Validate. `RELEASE-MEMBERSHIP.json` binds the +issue batch to that SHA through commit trailers and is itself covered by +`SHA256SUMS`; the post-publication bookkeeping never selects the live S8 queue. +The same manifest consumer makes local and workflow retries idempotent across +comment, label-removal and close failures. Existing release-event workflows are kept as an independent recovery path; they enforce the same exact-SHA gate. `Validate` contains only the candidate performance smoke so ordinary betas do not wait for a full comparison. Every `main` promotion starts the dedicated diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d87f868f..37febb1a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -401,16 +401,20 @@ operation; it does not modify the developer's Git configuration. It creates or verifies an annotated exact-SHA tag, builds `houseplan.zip` directly from that committed tree, verifies its manifest and embedded frontend against the candidate hash, -stages a draft prerelease and uploads both assets plus their `SHA256SUMS` -passport. Only then does it make the release public. It verifies the downloaded -public asset contents against the candidate and the passport, checks the -paginated HACS prerelease order, and finally closes only the explicitly supplied -issues and strips their status label. Nothing else re-uploads assets after -publication (#540): the bytes it verified are the bytes that stay. Re-running the same command -after a partial failure is safe when local/remote tags still resolve to the same -SHA: stale public assets are replaced and verified rather than accepted or left -for manual deletion. ZIP inspection is implemented in Node and does not depend -on the host's `tar`/`unzip` variant. A per-tag local lock and GitHub workflow +and creates `RELEASE-MEMBERSHIP.json`. Every explicitly supplied issue must have +an `Issue: #NN` trailer in the candidate history since the previous release; +issue authorship is irrelevant. The prerelease is staged as a draft and receives +both installable assets, the membership manifest and their `SHA256SUMS` passport. +Only then does it become public. The downloaded public bytes and membership are +verified against the candidate, followed by the paginated HACS order and +manifest-driven issue bookkeeping. Nothing else re-uploads assets after +publication (#540): the bytes it verified are the bytes that stay. Re-running +the same command after a partial failure is safe when the checkout still points +to the tagged candidate: stale assets are repaired, while a hidden per-release +comment marker, manifest membership and postcondition check make comment, +status-label removal and close individually repeatable (#547). ZIP inspection +is implemented in Node and does not depend on the host's `tar`/`unzip` variant. +A per-tag local lock and GitHub workflow concurrency reject parallel runs; the local lock is removed on normal exit and on handled `SIGHUP`/`SIGINT`/`SIGTERM` interruption (`SIGKILL` cannot be handled by any process). @@ -421,9 +425,12 @@ draft-first publication entirely on GitHub, including both assets. Prereleases are intentionally silent in Telegram; only stable releases are announced. GitHub exposes a `workflow_dispatch` button only after the workflow file exists on the default branch; until the next promotion to -`main`, use the local command. The button deliberately does not close Issues: -closing them is the release manager's call, and the `close-merged` job does it -from the beta itself (#120). +`main`, use the local command. The workflow snapshots the current S8 candidates +before publication, then retains only issues proven by Git history in the pinned +SHA. Its `close-merged` job consumes that immutable manifest after public asset +verification, so work merged later remains open and an accepted external issue +is treated like an owner-authored one. A retry reuses the published manifest and +resumes bookkeeping even when the release is already public (#120, #547). **Stable releases** go through `.github/workflows/release.yml`, the only publisher of installable assets (#540). Run it with `workflow_dispatch` on diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index f091a59b..82411ae4 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -7859,6 +7859,29 @@ const MUTANT_DEFINITIONS = [ replace: ' else if (false) mismatched.push(name);', }], }, + { + id: 'prerelease-membership-keeps-unproven-s8', + guard: 'node --test test/release-membership.test.mjs', + because: '#547: the mutable S8 queue is only a hint. Keeping an issue without an ' + + 'Issue trailer in the pinned candidate recreates the A -> B -> publish A race and ' + + 'closes B as if it had shipped', + patches: [{ + file: 'scripts/release-membership.mjs', + find: ' .filter((number) => evidence.get(number).length > 0)', + replace: ' .filter(() => true) // mutant: trust the live S8 snapshot', + }], + }, + { + id: 'prerelease-bookkeeping-duplicates-release-comment', + guard: 'node --test test/release-bookkeeping.test.mjs', + because: '#547: a failure after commenting but before label removal must be retryable; ' + + 'without the marker check every retry adds another public release comment', + patches: [{ + file: 'scripts/release-bookkeeping.mjs', + find: ' comment: !comments.some((body) => String(body).includes(marker)),', + replace: ' comment: true, // mutant: retries duplicate the comment', + }], + }, { id: 'render-invalidation-unknown-key-ignored', guard: 'node --test test/render-invalidation.test.mjs', diff --git a/scripts/release-assets.mjs b/scripts/release-assets.mjs index 2a940d9e..fa33edbf 100644 --- a/scripts/release-assets.mjs +++ b/scripts/release-assets.mjs @@ -19,9 +19,12 @@ import { createHash } from 'node:crypto'; import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { isMainModule } from './spawn-portable.mjs'; +import { MEMBERSHIP_FILE } from './release-membership.mjs'; /** Установочные ассеты — то, что скачивает HACS и человек. Ровно эти два. */ export const INSTALLABLE_ASSETS = ['houseplan-card.js', 'houseplan.zip']; +/** Candidate identity is not installable, but is part of the verified release. */ +export const PASSPORTED_ASSETS = [...INSTALLABLE_ASSETS, MEMBERSHIP_FILE]; export const SUMS_FILE = 'SHA256SUMS'; export const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex'); @@ -83,8 +86,9 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window const [command, dir, sumsPath] = positionals; if (command === 'sums') { if (!dir) throw new Error('usage: release-assets.mjs sums [--out=]'); - const entries = sumsOfDirectory(dir); - for (const name of INSTALLABLE_ASSETS) { + const names = flag('include-membership') ? PASSPORTED_ASSETS : INSTALLABLE_ASSETS; + const entries = sumsOfDirectory(dir, names); + for (const name of names) { if (!entries[name]) throw new Error(`${name} отсутствует в ${dir} — паспорт не выписывается на неполный набор`); } const out = value('out') || resolve(dir, SUMS_FILE); diff --git a/scripts/release-bookkeeping.mjs b/scripts/release-bookkeeping.mjs new file mode 100644 index 00000000..33071221 --- /dev/null +++ b/scripts/release-bookkeeping.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** Idempotent issue bookkeeping driven only by a verified release manifest. */ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { isMainModule } from './spawn-portable.mjs'; +import { MEMBERSHIP_FILE, verifyReleaseMembershipAgainstGit } from './release-membership.mjs'; + +export const releaseCommentMarker = (tag) => ``; + +export function planIssueBookkeeping({ state, labels = [], comments = [] }, tag) { + const marker = releaseCommentMarker(tag); + return { + comment: !comments.some((body) => String(body).includes(marker)), + removeLabels: labels.filter((name) => /^S\d+-/.test(name)).sort(), + close: state === 'OPEN', + }; +} + +export function finishManifestIssues({ manifest, tag, url, ops }) { + const marker = releaseCommentMarker(tag); + const body = `Выпущено в \`${tag}\` · [релиз](${url})\n\n${marker}`; + for (const { number } of manifest.issues) { + const action = planIssueBookkeeping(ops.load(number), tag); + if (action.comment) ops.comment(number, body); + for (const label of action.removeLabels) ops.removeLabel(number, label); + if (action.close) ops.close(number); + const final = ops.load(number); + if (final.state !== 'CLOSED' + || final.labels.some((label) => /^S\d+-/.test(label)) + || !final.comments.some((comment) => comment.includes(marker))) { + throw new Error(`bookkeeping for #${number} is incomplete after the attempted repair`); + } + } +} + +const parseArgs = (args) => { + const values = new Map(); + for (const arg of args) { + const match = /^--([^=]+)=(.*)$/.exec(arg); + if (!match) throw new Error(`unexpected argument: ${arg}`); + values.set(match[1], match[2]); + } + return values; +}; + +if (isMainModule(import.meta.url)) { + try { + const values = parseArgs(process.argv.slice(2)); + const repo = values.get('repo'); + const tag = values.get('tag'); + const candidate = values.get('candidate'); + const url = values.get('url'); + const membershipPath = values.get('membership') || MEMBERSHIP_FILE; + if (!repo || !tag || !candidate || !url) { + throw new Error('usage: release-bookkeeping.mjs --repo=... --tag=... --candidate=... --url=... [--membership=...]'); + } + const run = (args) => { + const result = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`); + } + return String(result.stdout || '').trim(); + }; + const manifest = verifyReleaseMembershipAgainstGit( + JSON.parse(readFileSync(membershipPath, 'utf8')), + { tag, candidate }, + ); + const load = (number) => { + const issue = JSON.parse(run([ + 'issue', 'view', String(number), '--repo', repo, '--json', 'state,labels', + ])); + const pages = JSON.parse(run([ + 'api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/comments?per_page=100`, + ]) || '[]'); + return { + state: issue.state, + labels: (issue.labels || []).map((label) => label.name), + comments: pages.flat().map((comment) => comment.body || ''), + }; + }; + finishManifestIssues({ + manifest, tag, url, + ops: { + load, + comment: (number, body) => run([ + 'issue', 'comment', String(number), '--repo', repo, '--body', body, + ]), + removeLabel: (number, label) => run([ + 'issue', 'edit', String(number), '--repo', repo, '--remove-label', label, + ]), + close: (number) => run([ + 'issue', 'close', String(number), '--repo', repo, '--reason', 'completed', + ]), + }, + }); + for (const { number } of manifest.issues) console.log(`bookkeeping complete for #${number}`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/release-membership.mjs b/scripts/release-membership.mjs new file mode 100644 index 00000000..4906623e --- /dev/null +++ b/scripts/release-membership.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +/** + * Candidate-bound issue membership for prereleases (#547). + * + * The manifest is deliberately deterministic and contains git evidence for + * every issue. An S8 label is only an input hint; it is never proof that an + * issue belongs to the pinned candidate. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { isMainModule } from './spawn-portable.mjs'; + +export const MEMBERSHIP_FILE = 'RELEASE-MEMBERSHIP.json'; +export const MEMBERSHIP_SCHEMA = 1; + +const SHA_RE = /^[0-9a-f]{40,64}$/; +const TAG_RE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +export function parseMembershipIssueList(value = '') { + if (!String(value).trim()) return []; + const parts = String(value).split(',').map((part) => part.trim()).filter(Boolean); + if (parts.some((part) => !/^[1-9]\d*$/.test(part))) { + throw new Error(`issues must be comma-separated positive numbers: ${value}`); + } + return [...new Set(parts.map(Number))].sort((a, b) => a - b); +} + +export function issueTrailers(message = '') { + const found = []; + for (const line of String(message).split(/\r?\n/)) { + const match = /^Issue:\s*#([1-9]\d*)\s*$/.exec(line.trim()); + if (match) found.push(Number(match[1])); + } + return [...new Set(found)]; +} + +export function buildReleaseMembership({ + tag, candidate, base = null, commits = [], issueNumbers = [], allowUnmatched = false, +}) { + const requested = [...new Set(issueNumbers.map(Number))].sort((a, b) => a - b); + const evidence = new Map(requested.map((number) => [number, []])); + for (const commit of commits) { + if (!SHA_RE.test(commit.sha)) throw new Error(`invalid commit SHA: ${commit.sha}`); + for (const number of issueTrailers(commit.message)) { + if (evidence.has(number)) evidence.get(number).push(commit.sha); + } + } + const unmatched = requested.filter((number) => evidence.get(number).length === 0); + if (unmatched.length && !allowUnmatched) { + throw new Error( + `issues are not proven in candidate ${candidate}: ${unmatched.map((n) => `#${n}`).join(', ')}`, + ); + } + const manifest = { + schema: MEMBERSHIP_SCHEMA, + tag, + candidate, + base, + issues: requested + .filter((number) => evidence.get(number).length > 0) + .map((number) => ({ number, commits: [...new Set(evidence.get(number))].sort() })), + }; + validateReleaseMembership(manifest, { tag, candidate }); + return { manifest, unmatched }; +} + +export function validateReleaseMembership(manifest, expected = {}) { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new Error('release membership must be a JSON object'); + } + if (manifest.schema !== MEMBERSHIP_SCHEMA) { + throw new Error(`unsupported release membership schema: ${manifest.schema}`); + } + if (!TAG_RE.test(manifest.tag || '')) throw new Error(`invalid membership tag: ${manifest.tag}`); + if (!SHA_RE.test(manifest.candidate || '')) { + throw new Error(`invalid membership candidate: ${manifest.candidate}`); + } + if (expected.tag && manifest.tag !== expected.tag) { + throw new Error(`membership tag ${manifest.tag} != expected ${expected.tag}`); + } + if (expected.candidate && manifest.candidate !== expected.candidate) { + throw new Error(`membership candidate ${manifest.candidate} != expected ${expected.candidate}`); + } + if (manifest.base !== null) { + if (!manifest.base || typeof manifest.base !== 'object' + || !TAG_RE.test(manifest.base.tag || '') || !SHA_RE.test(manifest.base.sha || '')) { + throw new Error('membership base must be null or { tag, sha }'); + } + } + if (!Array.isArray(manifest.issues)) throw new Error('membership issues must be an array'); + let previous = 0; + for (const issue of manifest.issues) { + if (!Number.isInteger(issue?.number) || issue.number <= previous) { + throw new Error('membership issues must be unique positive numbers in ascending order'); + } + if (!Array.isArray(issue.commits) || issue.commits.length === 0 + || issue.commits.some((sha) => !SHA_RE.test(sha))) { + throw new Error(`membership issue #${issue.number} has no valid commit evidence`); + } + if ([...new Set(issue.commits)].sort().join(',') !== issue.commits.join(',')) { + throw new Error(`membership issue #${issue.number} commit evidence must be unique and sorted`); + } + previous = issue.number; + } + return manifest; +} + +const git = (args, { allowFailure = false } = {}) => { + const result = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0 && !allowFailure) { + throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`); + } + return { ok: result.status === 0, stdout: String(result.stdout || '').trim() }; +}; + +export function readCandidateHistory(candidate) { + if (!SHA_RE.test(candidate)) throw new Error(`invalid candidate SHA: ${candidate}`); + const previous = git([ + 'describe', '--tags', '--abbrev=0', '--first-parent', '--match=v*', `${candidate}^`, + ], { allowFailure: true }); + const base = previous.ok && previous.stdout + ? { tag: previous.stdout, sha: git(['rev-list', '-n', '1', previous.stdout]).stdout } + : null; + const range = base ? `${base.sha}..${candidate}` : candidate; + const raw = git(['log', '--first-parent', '--format=%H%x1f%B%x1e', range]).stdout; + const commits = raw.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { + const separator = record.indexOf('\x1f'); + if (separator < 0) throw new Error('cannot parse candidate git history'); + return { sha: record.slice(0, separator).trim(), message: record.slice(separator + 1) }; + }); + return { base, commits }; +} + +export function verifyReleaseMembershipAgainstGit(manifest, expected = {}) { + validateReleaseMembership(manifest, expected); + const history = readCandidateHistory(manifest.candidate); + if (JSON.stringify(history.base) !== JSON.stringify(manifest.base)) { + throw new Error('membership base does not match the candidate history'); + } + const all = new Set(history.commits.map((commit) => commit.sha)); + const byIssue = new Map(); + for (const commit of history.commits) { + for (const number of issueTrailers(commit.message)) { + if (!byIssue.has(number)) byIssue.set(number, new Set()); + byIssue.get(number).add(commit.sha); + } + } + for (const issue of manifest.issues) { + for (const sha of issue.commits) { + if (!all.has(sha) || !byIssue.get(issue.number)?.has(sha)) { + throw new Error(`membership evidence ${sha} does not prove issue #${issue.number}`); + } + } + } + return manifest; +} + +const parseArgs = (args) => { + const [command, ...rest] = args; + const values = new Map(); + const switches = new Set(); + for (const arg of rest) { + const match = /^--([^=]+)(?:=(.*))?$/.exec(arg); + if (!match) throw new Error(`unexpected argument: ${arg}`); + if (match[2] === undefined) switches.add(match[1]); + else values.set(match[1], match[2]); + } + return { command, values, switches }; +}; + +if (isMainModule(import.meta.url)) { + try { + const { command, values, switches } = parseArgs(process.argv.slice(2)); + const tag = values.get('tag'); + const candidate = values.get('candidate'); + const input = resolve(values.get('input') || MEMBERSHIP_FILE); + if (command === 'create') { + const { base, commits } = readCandidateHistory(candidate); + const { manifest, unmatched } = buildReleaseMembership({ + tag, + candidate, + base, + commits, + issueNumbers: parseMembershipIssueList(values.get('issues')), + allowUnmatched: switches.has('allow-unmatched'), + }); + const output = resolve(values.get('output') || MEMBERSHIP_FILE); + writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`); + if (unmatched.length) { + console.log(`Excluded without candidate proof: ${unmatched.map((n) => `#${n}`).join(', ')}`); + } + console.log(`Membership: ${manifest.issues.map((row) => `#${row.number}`).join(', ') || '(empty)'}`); + } else if (command === 'verify' || command === 'list') { + const manifest = JSON.parse(readFileSync(input, 'utf8')); + verifyReleaseMembershipAgainstGit(manifest, { tag, candidate }); + if (command === 'list') console.log(manifest.issues.map((row) => row.number).join(',')); + else console.log(`Verified ${input}: ${manifest.issues.length} issue(s)`); + } else { + throw new Error('usage: release-membership.mjs create|verify|list --tag=... --candidate=...'); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/release-prerelease.mjs b/scripts/release-prerelease.mjs index c19b53fb..6cf24486 100644 --- a/scripts/release-prerelease.mjs +++ b/scripts/release-prerelease.mjs @@ -15,6 +15,10 @@ import { assertReleaseContract } from './release-contract.mjs'; import { classifyValidateProofs } from './release-gate.mjs'; import { assertBundleManifest } from './bundle-tree.mjs'; import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs'; +import { + MEMBERSHIP_FILE, buildReleaseMembership, readCandidateHistory, + verifyReleaseMembershipAgainstGit, +} from './release-membership.mjs'; const SUBPROCESS_MAX_BUFFER = 64 * 1024 * 1024; @@ -80,7 +84,7 @@ export function verifyReleaseProjection(release, { tag }) { if (release.isDraft) throw new Error(`GitHub release ${tag} is still a draft`); if (!release.isPrerelease) throw new Error(`GitHub release ${tag} is not marked as a prerelease`); const assets = new Map((release.assets || []).map((asset) => [asset.name, asset])); - for (const name of ['houseplan-card.js', 'houseplan.zip', SUMS_FILE]) { + for (const name of ['houseplan-card.js', 'houseplan.zip', MEMBERSHIP_FILE, SUMS_FILE]) { const asset = assets.get(name); if (!asset || !(Number(asset.size) > 0)) throw new Error(`Release asset ${name} is missing or empty`); } @@ -347,12 +351,13 @@ if (invokedDirectly) { return zipPath; }; - const verifyRemoteAssetContents = (version, bundleSnapshot) => { + const verifyRemoteAssetContents = (version, bundleSnapshot, candidate) => { const download = mkdtempSync(resolve(tmpdir(), 'houseplan-release-check-')); try { run('gh', [ 'release', 'download', tag, '--repo', repo, '--dir', download, - '--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', '--pattern', SUMS_FILE, '--clobber', + '--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', + '--pattern', MEMBERSHIP_FILE, '--pattern', SUMS_FILE, '--clobber', ]); try { const cardPath = resolve(download, 'houseplan-card.js'); @@ -360,12 +365,24 @@ if (invokedDirectly) { if (cardHash !== bundleSnapshot.entrySha256) throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSnapshot.entrySha256}`); verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSnapshot); + const membership = verifyReleaseMembershipAgainstGit( + JSON.parse(readFileSync(resolve(download, MEMBERSHIP_FILE), 'utf8')), + { tag, candidate }, + ); + if (issues.length) { + const actual = membership.issues.map((row) => row.number); + if (JSON.stringify(actual) !== JSON.stringify([...issues].sort((a, b) => a - b))) { + throw new Error(`Published membership ${actual.join(',')} != requested ${issues.join(',')}`); + } + } // #540: паспорт обязан быть и обязан описывать ровно эти байты. + const expectedSums = parseSums(readFileSync(resolve(download, SUMS_FILE), 'utf8')); const passport = compareSums( - parseSums(readFileSync(resolve(download, SUMS_FILE), 'utf8')), - sumsOfDirectory(download), + expectedSums, + sumsOfDirectory(download, Object.keys(expectedSums)), ); if (!passport.ok) throw new Error(`Published ${SUMS_FILE} disagrees with the assets: ${JSON.stringify(passport)}`); + return membership; } catch (error) { throw new ReleaseAssetContentError( error instanceof Error ? error.message : String(error), @@ -426,26 +443,11 @@ if (invokedDirectly) { // отсутствие задачи в проекте роняло публикацию. Статус живёт в метках // (PROCESS.md §9), и релизу нечего синхронизировать: он закрывает issue и // снимает статусную метку, как это делает job close-merged (#120). - const finishIssues = (releaseUrl) => { - if (!issues.length) return; - for (const issue of issues) { - const row = ghJson(['issue', 'view', String(issue), '--repo', repo, '--json', 'state,labels']); - if (row.state === 'OPEN') { - const status = (row.labels || []) - .map((label) => label.name) - .filter((name) => /^S\d-/.test(name)); - // Метка снимается ДО закрытия: инвариант «закрытый issue не несёт - // статусных меток» ломался уже дважды, и оба раза из-за обратного - // порядка в ручном шаге. - for (const name of status) { - run('gh', ['issue', 'edit', String(issue), '--repo', repo, '--remove-label', name]); - } - run('gh', [ - 'issue', 'close', String(issue), '--repo', repo, '--reason', 'completed', - '--comment', `Реализовано и опубликовано в [${tag}](${releaseUrl}).`, - ], { inherit: true }); - } - } + const finishIssues = (releaseUrl, sha, membershipPath) => { + run(process.execPath, [ + 'scripts/release-bookkeeping.mjs', `--repo=${repo}`, `--tag=${tag}`, + `--candidate=${sha}`, `--url=${releaseUrl}`, `--membership=${membershipPath}`, + ], { inherit: true }); }; const acquireReleaseLock = () => { @@ -482,14 +484,22 @@ if (invokedDirectly) { run('git', ['fetch', 'origin', branch]); const sha = run('git', ['rev-parse', 'HEAD']).stdout; const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout; - if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`); + const existingTag = remoteTag(); + if (existingTag.exists) { + if (existingTag.commit !== sha) { + throw new Error(`Remote tag ${tag} points to ${existingTag.commit}; check out that candidate before retrying`); + } + } else if (sha !== remoteBranch) { + throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`); + } const bundleSnapshot = assertBundleSnapshots(sha); const bundleSha256 = bundleSnapshot.entrySha256; const validateRuns = await assertGreenValidate(sha); validateIssues(); - const existingTag = remoteTag(); - if (existingTag.exists && existingTag.commit !== sha) - throw new Error(`Remote tag ${tag} points to ${existingTag.commit}, expected ${sha}`); + const history = readCandidateHistory(sha); + const generatedMembership = buildReleaseMembership({ + tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues, + }).manifest; const existingRelease = releaseView(); console.log(JSON.stringify({ @@ -525,6 +535,8 @@ if (invokedDirectly) { try { artifactsDir = mkdtempSync(resolve(tmpdir(), 'houseplan-release-')); const bundlePath = materializeCommittedBundle(sha, bundleSha256, artifactsDir); + const membershipPath = resolve(artifactsDir, MEMBERSHIP_FILE); + writeFileSync(membershipPath, `${JSON.stringify(generatedMembership, null, 2)}\n`); if (existingRelease && !existingRelease.isDraft) { let complete; try { @@ -537,7 +549,8 @@ if (invokedDirectly) { // A matching name and non-zero size are insufficient: bind both // downloadable assets to this exact candidate before closing issues. try { - verifyRemoteAssetContents(contract.version, bundleSnapshot); + const publishedMembership = verifyRemoteAssetContents(contract.version, bundleSnapshot, sha); + writeFileSync(membershipPath, `${JSON.stringify(publishedMembership, null, 2)}\n`); } catch (error) { if (!(error instanceof ReleaseAssetContentError)) throw error; console.log(`Published release needs stale-asset recovery: ${error.message}`); @@ -545,7 +558,7 @@ if (invokedDirectly) { } if (complete) { verifyHacsDiscovery(); - finishIssues(complete.url); + finishIssues(complete.url, sha, membershipPath); console.log(`Already published and content-verified: ${complete.url}`); return; } @@ -579,14 +592,15 @@ if (invokedDirectly) { writeFileSync(sumsPath, formatSums({ 'houseplan-card.js': sha256Path(bundlePath), 'houseplan.zip': sha256Path(zipPath), + [MEMBERSHIP_FILE]: sha256Path(membershipPath), })); run('gh', [ - 'release', 'upload', tag, bundlePath, zipPath, sumsPath, + 'release', 'upload', tag, bundlePath, zipPath, membershipPath, sumsPath, '--repo', repo, '--clobber', ], { inherit: true }); const staged = releaseView(); const stagedAssets = new Map((staged?.assets || []).map((asset) => [asset.name, asset])); - for (const name of ['houseplan-card.js', 'houseplan.zip', SUMS_FILE]) { + for (const name of ['houseplan-card.js', 'houseplan.zip', MEMBERSHIP_FILE, SUMS_FILE]) { if (!(Number(stagedAssets.get(name)?.size) > 0)) throw new Error(`Draft release asset ${name} is missing or empty`); } @@ -600,9 +614,9 @@ if (invokedDirectly) { const finalTag = remoteTag(); if (!finalTag.exists || finalTag.commit !== sha) throw new Error(`Published tag ${tag} no longer resolves to exact SHA ${sha}`); - verifyRemoteAssetContents(contract.version, bundleSnapshot); + verifyRemoteAssetContents(contract.version, bundleSnapshot, sha); verifyHacsDiscovery(); - finishIssues(published.url); + finishIssues(published.url, sha, membershipPath); console.log(`Published and content-verified: ${published.url}`); } finally { for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler); diff --git a/test/release-assets.test.mjs b/test/release-assets.test.mjs index 851d7af1..a69a15c1 100644 --- a/test/release-assets.test.mjs +++ b/test/release-assets.test.mjs @@ -8,15 +8,19 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { - INSTALLABLE_ASSETS, SUMS_FILE, compareSums, formatSums, parseSums, sha256Hex, sumsOfDirectory, + INSTALLABLE_ASSETS, PASSPORTED_ASSETS, SUMS_FILE, compareSums, formatSums, parseSums, + sha256Hex, sumsOfDirectory, } from '../scripts/release-assets.mjs'; const A = 'a'.repeat(64); const B = 'b'.repeat(64); const C = 'c'.repeat(64); -test('#540: the passport covers exactly the two installable assets, in sha256sum format, sorted', () => { +test('#540/#547: installables stay two while the passport also binds release membership', () => { assert.deepEqual(INSTALLABLE_ASSETS, ['houseplan-card.js', 'houseplan.zip']); + assert.deepEqual(PASSPORTED_ASSETS, [ + 'houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', + ]); assert.equal(SUMS_FILE, 'SHA256SUMS'); const text = formatSums({ 'houseplan.zip': A, 'houseplan-card.js': B }); assert.equal(text, `${B} houseplan-card.js\n${A} houseplan.zip\n`); @@ -49,14 +53,20 @@ test('#540: the CLI writes the passport from real files and refuses an incomplet assert.match(r.stderr, /houseplan\.zip отсутствует/); writeFileSync(join(dir, 'houseplan.zip'), 'zip'); - r = spawnSync(process.execPath, [script, 'sums', dir], { encoding: 'utf8' }); + r = spawnSync(process.execPath, [script, 'sums', dir, '--include-membership'], { encoding: 'utf8' }); + assert.equal(r.status, 1, 'candidate membership is part of a prerelease passport'); + assert.match(r.stderr, /RELEASE-MEMBERSHIP\.json отсутствует/); + + writeFileSync(join(dir, 'RELEASE-MEMBERSHIP.json'), '{"schema":1}\n'); + r = spawnSync(process.execPath, [script, 'sums', dir, '--include-membership'], { encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); const sums = readFileSync(join(dir, SUMS_FILE), 'utf8'); assert.deepEqual(parseSums(sums), { 'houseplan-card.js': sha256Hex(Buffer.from('card')), 'houseplan.zip': sha256Hex(Buffer.from('zip')), + 'RELEASE-MEMBERSHIP.json': sha256Hex(Buffer.from('{"schema":1}\n')), }); - assert.deepEqual(sumsOfDirectory(dir), parseSums(sums)); + assert.deepEqual(sumsOfDirectory(dir, PASSPORTED_ASSETS), parseSums(sums)); r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE)], { encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); diff --git a/test/release-bookkeeping.test.mjs b/test/release-bookkeeping.test.mjs new file mode 100644 index 00000000..d8575342 --- /dev/null +++ b/test/release-bookkeeping.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + finishManifestIssues, planIssueBookkeeping, releaseCommentMarker, +} from '../scripts/release-bookkeeping.mjs'; + +const tag = 'v1.76.0-beta.1'; + +test('#547: first bookkeeping pass comments, strips status and closes', () => { + assert.deepEqual(planIssueBookkeeping({ + state: 'OPEN', labels: ['bug', 'S8-merged'], comments: [], + }, tag), { + comment: true, removeLabels: ['S8-merged'], close: true, + }); +}); + +test('#547 AC3: every partial-failure retry converges without duplicate comments', () => { + const marker = releaseCommentMarker(tag); + assert.deepEqual(planIssueBookkeeping({ + state: 'OPEN', labels: ['S8-merged'], comments: [`released\n${marker}`], + }, tag), { + comment: false, removeLabels: ['S8-merged'], close: true, + }, 'retry after comment failure does not comment twice'); + assert.deepEqual(planIssueBookkeeping({ + state: 'OPEN', labels: ['bug'], comments: [`released\n${marker}`], + }, tag), { + comment: false, removeLabels: [], close: true, + }, 'retry after label removal still closes from the manifest'); + assert.deepEqual(planIssueBookkeeping({ + state: 'CLOSED', labels: ['S8-merged'], comments: [`released\n${marker}`], + }, tag), { + comment: false, removeLabels: ['S8-merged'], close: false, + }, 'retry after close repairs a leftover status label'); + assert.deepEqual(planIssueBookkeeping({ + state: 'CLOSED', labels: ['bug'], comments: [`released\n${marker}`], + }, tag), { + comment: false, removeLabels: [], close: false, + }); +}); + +test('#547 AC1/AC3: manifest retry repairs uncertain steps and never touches later S8 work', () => { + const rows = new Map([ + [10, { state: 'OPEN', labels: ['bug', 'S8-merged'], comments: [] }], + [11, { state: 'OPEN', labels: ['S8-merged'], comments: [] }], + ]); + const calls = []; + const ops = { + load(number) { + calls.push(`load:${number}`); + return structuredClone(rows.get(number)); + }, + comment(number, body) { + calls.push(`comment:${number}`); + rows.get(number).comments.push(body); + }, + removeLabel(number, label) { + calls.push(`remove:${number}`); + rows.get(number).labels = rows.get(number).labels.filter((name) => name !== label); + }, + close(number) { + calls.push(`close:${number}`); + rows.get(number).state = 'CLOSED'; + }, + }; + const input = { manifest: { issues: [{ number: 10 }] }, tag, url: 'https://example.test/release', ops }; + + const originalRemove = ops.removeLabel; + let first = true; + ops.removeLabel = (...args) => { + if (first) { first = false; throw new Error('label API failed'); } + originalRemove(...args); + }; + assert.throws(() => finishManifestIssues(input), /label API failed/); + assert.equal(rows.get(10).comments.length, 1); + finishManifestIssues(input); + assert.equal(rows.get(10).comments.length, 1, 'release comment is not duplicated'); + assert.deepEqual(rows.get(10), { + state: 'CLOSED', labels: ['bug'], comments: rows.get(10).comments, + }); + assert.deepEqual(rows.get(11), { + state: 'OPEN', labels: ['S8-merged'], comments: [], + }, 'B is not a member of candidate A and remains untouched'); + assert.equal(calls.some((call) => call.endsWith(':11')), false); +}); + +test('#547 AC3: an uncertain close response is harmless on retry', () => { + const row = { state: 'OPEN', labels: [], comments: [] }; + let closeCalls = 0; + const ops = { + load: () => structuredClone(row), + comment: (_number, body) => row.comments.push(body), + removeLabel: () => {}, + close: () => { + closeCalls++; + row.state = 'CLOSED'; + if (closeCalls === 1) throw new Error('connection lost after close'); + }, + }; + const input = { manifest: { issues: [{ number: 10 }] }, tag, url: 'https://example.test/release', ops }; + assert.throws(() => finishManifestIssues(input), /connection lost/); + finishManifestIssues(input); + assert.equal(closeCalls, 1, 'retry observes the persisted close instead of closing again'); + assert.equal(row.comments.length, 1); +}); diff --git a/test/release-contract.test.mjs b/test/release-contract.test.mjs index 976db9a9..157ed4b9 100644 --- a/test/release-contract.test.mjs +++ b/test/release-contract.test.mjs @@ -138,7 +138,12 @@ test('local orchestrator validates issue lists and public release assets', () => assert.throws(() => parsePrereleaseArgs([tag, 'extra']), /Exactly one/); const release = { tagName: tag, isDraft: false, isPrerelease: true, - assets: [{ name: 'houseplan-card.js', size: 10 }, { name: 'houseplan.zip', size: 20 }, { name: 'SHA256SUMS', size: 5 }], + assets: [ + { name: 'houseplan-card.js', size: 10 }, + { name: 'houseplan.zip', size: 20 }, + { name: 'RELEASE-MEMBERSHIP.json', size: 30 }, + { name: 'SHA256SUMS', size: 5 }, + ], }; assert.equal(verifyReleaseProjection(release, { tag }), release); assert.throws( @@ -151,7 +156,9 @@ test('local orchestrator validates issue lists and public release assets', () => ); // #540: паспорт — часть единого вида релиза; бета без него неполна. assert.throws( - () => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 2) }, { tag }), + () => verifyReleaseProjection({ + ...release, assets: release.assets.filter((asset) => asset.name !== 'SHA256SUMS'), + }, { tag }), /SHA256SUMS/, ); const orchestrator = readFileSync( @@ -160,9 +167,9 @@ test('local orchestrator validates issue lists and public release assets', () => // #540: после публикации никто не ждёт независимых републикаторов — их нет. assert.ok(!/waitForReleaseWorkflows|release-zip\.yml|prereleaseWorkflowSucceeded/.test(orchestrator), 'the local publisher no longer waits for release.yml/release-zip.yml to re-upload what it already verified'); - assert.match(orchestrator, /formatSums\(\{\n\s+'houseplan-card\.js': sha256Path\(bundlePath\),\n\s+'houseplan\.zip': sha256Path\(zipPath\),/, + assert.match(orchestrator, /formatSums\(\{\n\s+'houseplan-card\.js': sha256Path\(bundlePath\),\n\s+'houseplan\.zip': sha256Path\(zipPath\),\n\s+\[MEMBERSHIP_FILE\]: sha256Path\(membershipPath\),/, 'the passport is computed from the very files that are uploaded'); - assert.match(orchestrator, /'release', 'upload', tag, bundlePath, zipPath, sumsPath,/); + assert.match(orchestrator, /'release', 'upload', tag, bundlePath, zipPath, membershipPath, sumsPath,/); }); test('release ZIP inspection is portable and does not depend on tar', () => { @@ -227,9 +234,9 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained 'node scripts/release-contract.mjs', 'node scripts/release-gate.mjs', '--draft --prerelease', - "'houseplan-card.js', 'houseplan.zip', 'SHA256SUMS'", + "'houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS'", 'test -s dist/houseplan-panel.js', - 'node scripts/release-assets.mjs sums release-assets', + 'node scripts/release-assets.mjs sums release-assets --include-membership', 'node scripts/release-assets.mjs check public release-assets/SHA256SUMS', 'git -c core.autocrlf=false archive --format=zip --output=houseplan.zip', '--draft=false --prerelease', @@ -238,6 +245,13 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained "if: ${{ needs.publish.outputs.newly_published == 'true' }}", 'uses: ./.github/workflows/announce.yml', ]) assert.ok(workflow.includes(required), `missing workflow contract: ${required}`); + assert.ok(workflow.includes('node scripts/release-membership.mjs create')); + assert.ok(workflow.includes('node scripts/release-bookkeeping.mjs')); + assert.ok(workflow.includes('release is already public and byte-identical; publication skipped')); + assert.ok(!workflow.includes('--author Matysh')); + const closeJob = workflow.slice(workflow.indexOf(' close-merged:'), workflow.indexOf(' announce:')); + assert.ok(!closeJob.includes('newly_published'), 'verified retries must resume bookkeeping'); + assert.ok(!closeJob.includes('gh issue list'), 'closing is manifest-driven, never a fresh S8 snapshot'); assert.ok(workflow.indexOf('gh release upload') < workflow.indexOf('--draft=false --prerelease')); const announce = readFileSync(new URL('../.github/workflows/announce.yml', import.meta.url), 'utf8'); diff --git a/test/release-membership.test.mjs b/test/release-membership.test.mjs new file mode 100644 index 00000000..f8bef0e7 --- /dev/null +++ b/test/release-membership.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildReleaseMembership, issueTrailers, validateReleaseMembership, +} from '../scripts/release-membership.mjs'; + +const A = 'a'.repeat(40); +const B = 'b'.repeat(40); +const BASE = '0'.repeat(40); + +test('#547: S8 is only a hint; candidate trailers are the membership proof', () => { + const result = buildReleaseMembership({ + tag: 'v1.76.0-beta.1', + candidate: A, + base: { tag: 'v1.75.0', sha: BASE }, + commits: [{ sha: A, message: 'feat: included\n\nIssue: #10\nUser-Visible: yes' }], + issueNumbers: [10, 11], + allowUnmatched: true, + }); + assert.deepEqual(result.manifest.issues, [{ number: 10, commits: [A] }]); + assert.deepEqual(result.unmatched, [11], 'B merged after candidate A remains outside the release'); + assert.throws(() => buildReleaseMembership({ + tag: 'v1.76.0-beta.1', candidate: A, commits: [], issueNumbers: [11], + }), /#11/); +}); + +test('#547: accepted external issues use the exact same proof and no author field', () => { + const { manifest } = buildReleaseMembership({ + tag: 'v1.76.0-beta.1', + candidate: B, + commits: [{ sha: B, message: 'fix: contributor work\n\nIssue: #73\nUser-Visible: no' }], + issueNumbers: [73], + }); + assert.deepEqual(manifest.issues, [{ number: 73, commits: [B] }]); + assert.equal('author' in manifest.issues[0], false); +}); + +test('#547: manifests are deterministic and reject candidate or evidence drift', () => { + assert.deepEqual(issueTrailers('x\nIssue: #2\nIssue: #2\nIssue: #7'), [2, 7]); + const { manifest } = buildReleaseMembership({ + tag: 'v1.76.0-beta.1', candidate: A, + commits: [ + { sha: B, message: 'Issue: #7' }, + { sha: A, message: 'Issue: #7\nIssue: #2' }, + ], + issueNumbers: [7, 2], + }); + assert.deepEqual(manifest.issues, [ + { number: 2, commits: [A] }, + { number: 7, commits: [A, B] }, + ]); + assert.throws(() => validateReleaseMembership(manifest, { candidate: B }), /expected/); + assert.throws(() => validateReleaseMembership({ + ...manifest, issues: [{ number: 7, commits: [] }], + }), /no valid commit evidence/); +});