Skip to content

Commit f6ed321

Browse files
committed
feat: add deterministic release candidate inspection
1 parent 23d88bf commit f6ed321

10 files changed

Lines changed: 193 additions & 6 deletions

File tree

.agents/skills/release-portreeve/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ gate.
2929
- For a local engine check, run `release:prepare`. Explain that one host stops before
3030
the complete native matrix.
3131
- For a complete candidate or rehearsal, use the manual GitHub workflow with
32-
`publish=false`. Download and inspect `distribution-<version>` after it succeeds.
32+
`publish=false`. Download `distribution-<version>` after it succeeds and run
33+
`release:inspect` against its `release-record.json`.
3334
- For an interrupted candidate, resume only through the exact recovery path documented
3435
in `docs/releasing.md`; do not rebuild or substitute bytes downstream.
3536
- For a request to publish, first identify the exact finalized record and show the exact

docs/issues/tb-portreeve-preview-release/decisions.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,26 @@ rejected because they evolve independently. Recommend command-line Gatekeeper or
161161
quarantine bypasses - rejected because a scoped user-reviewed exception is safer.
162162

163163
**Promoted:** 2026-08-16. PR: #61.
164+
165+
---
166+
167+
## Make candidate inspection an explicit read-only release command
168+
169+
**Confidence:** HIGH
170+
171+
**Blast Radius:** Release operator workflow, downloaded workflow artifacts, final rehearsal, and publication review
172+
173+
Add `release:inspect` as a deterministic, non-mutating consumer of a finalized release
174+
record. It revalidates every recorded artifact, the complete native and Desktop
175+
matrices, required distribution types, and the exact publication plan before reporting
176+
that a candidate is ready for human publication review.
177+
178+
**Triggered by:** P9 needs a reproducible way to inspect the hosted `publish=false`
179+
candidate rather than relying on manual filename review
180+
181+
**Alternatives considered:**
182+
Reuse `release:publish` without confirmation - rejected because inspection should not
183+
instantiate remote adapters or share a mutation-oriented command. Inspect files by hand
184+
- rejected because that would not be a deterministic operator entry point.
185+
186+
**Promoted:** 2026-08-17. PR: #62.

docs/issues/tb-portreeve-preview-release/issues.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ metadata.
8282

8383
## I-7 - Alpha preview user experience
8484

85-
- **Status:** in-review
85+
- **Status:** closed
8686
- **Estimate:** 1d
8787
- **Plan steps:** P7
8888
- **Rubric criteria:** R7
@@ -95,7 +95,7 @@ coverage.
9595

9696
## I-8 - Operator runbook and release skill
9797

98-
- **Status:** in-review
98+
- **Status:** closed
9999
- **Estimate:** 1d
100100
- **Plan steps:** P8
101101
- **Rubric criteria:** R8
@@ -107,7 +107,7 @@ and drift tests that keep every invocation surface aligned.
107107

108108
## I-9 - Full release rehearsal and final verification
109109

110-
- **Status:** open
110+
- **Status:** in-progress
111111
- **Estimate:** 1d
112112
- **Plan steps:** P9
113113
- **Rubric criteria:** R1, R2, R3, R4, R5, R6, R7, R8

docs/issues/tb-portreeve-preview-release/scratchpad.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,24 @@ Show the warning only on the installation page - rejected because product maturi
151151
remain visible after installation. Treat alpha, preview, and unsigned as one state -
152152
rejected because they evolve independently. Recommend command-line Gatekeeper or
153153
quarantine bypasses - rejected because a scoped user-reviewed exception is safer.
154+
155+
## [8] Make candidate inspection an explicit read-only release command
156+
157+
[x] **Promote**
158+
159+
**Confidence:** HIGH
160+
161+
**Blast Radius:** Release operator workflow, downloaded workflow artifacts, final rehearsal, and publication review
162+
163+
Add `release:inspect` as a deterministic, non-mutating consumer of a finalized release
164+
record. It revalidates every recorded artifact, the complete native and Desktop
165+
matrices, required distribution types, and the exact publication plan before reporting
166+
that a candidate is ready for human publication review.
167+
168+
**Triggered by:** P9 needs a reproducible way to inspect the hosted `publish=false`
169+
candidate rather than relying on manual filename review
170+
171+
**Alternatives considered:**
172+
Reuse `release:publish` without confirmation - rejected because inspection should not
173+
instantiate remote adapters or share a mutation-oriented command. Inspect files by hand
174+
- rejected because that would not be a deterministic operator entry point.

docs/issues/tb-portreeve-preview-release/tracker.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,12 @@
7474
### PR #61 - Alpha preview UX and operator entry point
7575

7676
- **PR:** [#61](https://github.com/TrentBrown/portreeve/pull/61)
77-
- **Status:** in review
77+
- **Status:** merged
7878
- **Scope:** P7-P8 / I-7-I-8: persistent README/Desktop alpha identity, safe
7979
unsigned installation and removal guidance, release-note and cask caveats,
8080
one script-owned operator runbook, and a project-local release skill that
8181
preserves the publication gate.
8282
- **Evidence packet:** [pr-61](pr-61/)
83-
- **Result:** Pending slice gates. R7 and R8 gain their user and operator
83+
- **Result:** Slice gates passed. R7 and R8 gain their user and operator
8484
surfaces; the complete hosted rehearsal and feature-final evaluation remain
8585
P9 / I-9.

docs/releasing.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ gh run list --workflow release.yml
117117
gh run download RUN_ID --name distribution-0.1.0-preview.1
118118
```
119119

120+
Revalidate the downloaded record, every recorded byte, both matrices, and the exact
121+
publication plan without publishing:
122+
123+
```sh
124+
bun run release:inspect -- \
125+
--record distribution-0.1.0-preview.1/release-record.json \
126+
--json
127+
```
128+
120129
Inspect at least:
121130

122131
- workflow conclusions for all six native jobs;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"mcp:release-verify": "\"$npm_execpath\" run toolchain:check && \"$npm_execpath\" scripts/verify-mcp-release.js",
3434
"release:build": "\"$npm_execpath\" run toolchain:check && \"$npm_execpath\" scripts/release.js",
3535
"release:finalize-desktop": "\"$npm_execpath\" run toolchain:check && \"$npm_execpath\" scripts/finalize-desktop-distribution.js",
36+
"release:inspect": "\"$npm_execpath\" run toolchain:check && \"$npm_execpath\" scripts/inspect-release-candidate.js",
3637
"release:merge-native-evidence": "\"$npm_execpath\" run toolchain:check && \"$npm_execpath\" scripts/merge-native-release-evidence.js",
3738
"release:native-evidence": "\"$npm_execpath\" run toolchain:check && \"$npm_execpath\" scripts/collect-native-release-evidence.js",
3839
"release:package-desktop": "\"$npm_execpath\" run toolchain:check && \"$npm_execpath\" scripts/package-desktop-release.js",
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// @ts-check
2+
3+
import { Command } from 'commander';
4+
import { readFile } from 'node:fs/promises';
5+
import { dirname, resolve } from 'node:path';
6+
import { pathToFileURL } from 'node:url';
7+
import { createPublicationPlan, renderPublicationPlan } from './publication-plan.js';
8+
import { sha256File } from './release-lib.js';
9+
import { readReleaseRecord, verifyReleaseArtifacts } from './release-record.js';
10+
11+
const NATIVE_TARGETS = ['macos-arm64', 'macos-x64', 'linux-arm64', 'linux-x64'];
12+
const DESKTOP_ARCHITECTURES = ['arm64', 'x64'];
13+
14+
/** @param {{recordPath: string}} options */
15+
export async function inspectReleaseCandidate(options) {
16+
const recordPath = resolve(options.recordPath);
17+
const releaseRoot = dirname(recordPath);
18+
const record = await readReleaseRecord(recordPath);
19+
if (
20+
record.state !== 'prepared' ||
21+
record.stages.at(-1)?.name !== 'distribution-finalized' ||
22+
record.publication.state !== 'unpublished'
23+
) {
24+
throw new Error(
25+
'Candidate inspection requires a finalized, unpublished prepared release.',
26+
);
27+
}
28+
await verifyReleaseArtifacts(record, releaseRoot);
29+
30+
const nativeTargets = record.verifications.map((verification) => {
31+
const target = /** @type {{operatingSystem: string, architecture: string}} */ (
32+
verification.target
33+
);
34+
return `${target.operatingSystem}-${target.architecture}`;
35+
});
36+
if (nativeTargets.join(',') !== NATIVE_TARGETS.join(',')) {
37+
throw new Error(
38+
'Candidate does not contain the complete native verification matrix.',
39+
);
40+
}
41+
const desktopArchitectures = record.artifacts
42+
.filter((artifact) => artifact.type === 'desktop-dmg')
43+
.map((artifact) => artifact.architecture);
44+
if (desktopArchitectures.join(',') !== DESKTOP_ARCHITECTURES.join(',')) {
45+
throw new Error(
46+
'Candidate does not contain both architecture-specific Desktop DMGs.',
47+
);
48+
}
49+
for (const type of [
50+
'homebrew-formula',
51+
'homebrew-cask',
52+
'desktop-update-metadata',
53+
'release-metadata',
54+
'npm-package',
55+
]) {
56+
if (!record.artifacts.some((artifact) => artifact.type === type)) {
57+
throw new Error(`Candidate is missing required artifact type ${type}.`);
58+
}
59+
}
60+
61+
const planPath = resolve(releaseRoot, 'publication-plan.md');
62+
const expectedPlan = renderPublicationPlan(createPublicationPlan(record));
63+
if ((await readFile(planPath, 'utf8')) !== expectedPlan) {
64+
throw new Error('Candidate publication plan does not match its release record.');
65+
}
66+
67+
return {
68+
schemaVersion: 1,
69+
status: 'ready-for-publication-review',
70+
releaseId: record.releaseId,
71+
releaseVersion: record.releaseVersion,
72+
source: { ...record.source },
73+
versions: { ...record.versions },
74+
policy: { ...record.policy },
75+
state: record.state,
76+
publicationState: record.publication.state,
77+
artifactCount: record.artifacts.length,
78+
nativeTargets,
79+
desktopArchitectures,
80+
publicationPlanSha256: await sha256File(planPath),
81+
publicMutationPerformed: false,
82+
};
83+
}
84+
85+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
86+
const program = new Command()
87+
.name('release:inspect')
88+
.description('Inspect a finalized release candidate without publishing it')
89+
.requiredOption('--record <path>', 'finalized release-record.json path')
90+
.option('--json', 'emit the inspection summary as JSON', false)
91+
.action(async (values) => {
92+
const result = await inspectReleaseCandidate({ recordPath: values.record });
93+
if (values.json) {
94+
console.log(JSON.stringify(result, null, 2));
95+
return;
96+
}
97+
console.log(
98+
`Verified ${result.releaseId} from ${result.source.commit}: ` +
99+
`${result.artifactCount} artifacts, ${result.nativeTargets.length} native targets, ` +
100+
`${result.desktopArchitectures.length} Desktop DMGs; no public mutation performed.`,
101+
);
102+
});
103+
await program.parseAsync();
104+
}

test/release/documentation.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ test('release runbook and project skill preserve one script-owned contract', asy
212212
}
213213
for (const command of [
214214
'bun run release:prepare',
215+
'bun run release:inspect',
215216
'bun run release:publish',
216217
'gh workflow run release.yml',
217218
'-f publish=false',
@@ -221,9 +222,11 @@ test('release runbook and project skill preserve one script-owned contract', asy
221222
expect(skill).toContain('Read `docs/releasing.md` completely');
222223
expect(skill).toContain('A general request to prepare');
223224
expect(skill).toContain('Do not set `publish=true`');
225+
expect(skill).toContain('`release:inspect`');
224226
expect(skill).toContain('Never bypass `--confirm`');
225227
expect(skillMetadata).toContain('$release-portreeve');
226228
expect(packageJson.scripts).toHaveProperty('release:prepare');
229+
expect(packageJson.scripts).toHaveProperty('release:inspect');
227230
expect(packageJson.scripts).toHaveProperty('release:publish');
228231
expect(workflow).toContain('channel:');
229232
expect(workflow).toContain('version:');

test/release/publication.test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
66
import { join } from 'node:path';
77
import { desktopDmgName } from '../../scripts/desktop-release-lib.js';
88
import { finalizeDesktopDistribution } from '../../scripts/finalize-desktop-distribution.js';
9+
import { inspectReleaseCandidate } from '../../scripts/inspect-release-candidate.js';
910
import {
1011
createNativeVerification,
1112
mergeNativeVerifications,
@@ -30,6 +31,30 @@ afterEach(async () => {
3031
});
3132

3233
describe('release publication', () => {
34+
test('inspects a complete candidate without changing publication state', async () => {
35+
const recordPath = await finalizedRelease();
36+
const result = await inspectReleaseCandidate({ recordPath });
37+
expect(result).toMatchObject({
38+
status: 'ready-for-publication-review',
39+
releaseVersion: '0.1.0-preview.1',
40+
state: 'prepared',
41+
publicationState: 'unpublished',
42+
nativeTargets: ['macos-arm64', 'macos-x64', 'linux-arm64', 'linux-x64'],
43+
desktopArchitectures: ['arm64', 'x64'],
44+
publicMutationPerformed: false,
45+
});
46+
expect(result.publicationPlanSha256).toMatch(/^[a-f0-9]{64}$/u);
47+
expect((await readReleaseRecord(recordPath)).state).toBe('prepared');
48+
});
49+
50+
test('rejects a candidate whose review plan no longer matches its record', async () => {
51+
const recordPath = await finalizedRelease();
52+
await writeFile(join(recordPath, '..', 'publication-plan.md'), 'changed plan\n');
53+
await expect(inspectReleaseCandidate({ recordPath })).rejects.toThrow(
54+
'publication plan does not match',
55+
);
56+
});
57+
3358
test('requires explicit confirmation before adapter preflight or mutation', async () => {
3459
const recordPath = await finalizedRelease();
3560
/** @type {string[]} */

0 commit comments

Comments
 (0)