fix(release): bind beta bookkeeping to candidate (#547)

Issue: #547
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-13 10:35:43 +03:00
parent f34ca4090b
commit 6c6f53491f
14 changed files with 722 additions and 118 deletions
+94 -48
View File
@@ -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: Комментарий о публикации
+9 -5
View File
@@ -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).
+11 -4
View File
@@ -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 Страховка и разбор
+5 -1
View File
@@ -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
+20 -13
View File
@@ -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
+23
View File
@@ -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',
+6 -2
View File
@@ -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 <dir> [--out=<file>]');
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);
+102
View File
@@ -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) => `<!-- houseplan-release:${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;
}
}
+207
View File
@@ -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;
}
}
+49 -35
View File
@@ -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);
+14 -4
View File
@@ -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);
+105
View File
@@ -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);
});
+20 -6
View File
@@ -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');
+57
View File
@@ -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/);
});