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/);
+});