mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix(release): bind beta bookkeeping to candidate (#547)
Issue: #547 User-Visible: no
This commit is contained in:
@@ -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: Комментарий о публикации
|
||||
|
||||
@@ -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
@@ -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 Страховка и разбор
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user