diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b52926..35995be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,12 @@ jobs: - name: Test public release metadata and documentation run: npm run smoke:public-release + - name: Test release changelog and curated notes + run: npm run smoke:release-notes + + - name: Test published release closure verifier + run: npm run smoke:published-release + - name: Test release metadata contract run: npm run smoke:release-metadata diff --git a/.github/workflows/release-unsigned.yml b/.github/workflows/release-unsigned.yml index f710cea..0f1a649 100644 --- a/.github/workflows/release-unsigned.yml +++ b/.github/workflows/release-unsigned.yml @@ -22,6 +22,16 @@ on: required: false default: '' type: string + creator_qualification: + description: "Human declaration required for publication: NOT_REQUIRED or PASSED_PHYSICAL_MAC" + required: false + default: '' + type: string + qualification_reference: + description: Durable issue, PR/comment URL, or test record for PASSED_PHYSICAL_MAC + required: false + default: '' + type: string permissions: {} @@ -66,12 +76,17 @@ jobs: PUBLISH: ${{ inputs.publish }} REAL_MODEL: ${{ inputs.real_model }} CONFIRMATION: ${{ inputs.confirmation }} + CREATOR_QUALIFICATION: ${{ inputs.creator_qualification }} + QUALIFICATION_REFERENCE: ${{ inputs.qualification_reference }} run: | test "$(uname -m)" = "arm64" test "$(node -p process.arch)" = "arm64" git fetch --tags --force origin git tag --list > "$RUNNER_TEMP/scriptcut-existing-tags.txt" node scripts/prepare-public-release.js --validate-tag --tag "$RELEASE_TAG" --existing-tags-file "$RUNNER_TEMP/scriptcut-existing-tags.txt" + node scripts/check-published-release.js --validate-qualification \ + --creator-qualification "$CREATOR_QUALIFICATION" \ + --qualification-reference "$QUALIFICATION_REFERENCE" if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "Release already exists: $RELEASE_TAG" exit 1 @@ -80,6 +95,10 @@ jobs: test "$GITHUB_REF" = "refs/heads/main" test "$REAL_MODEL" = "true" test "$CONFIRMATION" = "PUBLISH_UNSIGNED_ALPHA" + test "$CREATOR_QUALIFICATION" = "NOT_REQUIRED" || test "$CREATOR_QUALIFICATION" = "PASSED_PHYSICAL_MAC" + if [ "$CREATOR_QUALIFICATION" = "PASSED_PHYSICAL_MAC" ]; then + test -n "$QUALIFICATION_REFERENCE" + fi fi - name: Build ad-hoc self-contained candidate @@ -118,12 +137,18 @@ jobs: - name: Prepare public release bundle env: RELEASE_TAG: ${{ inputs.release_tag }} + PUBLISH: ${{ inputs.publish }} run: | + NOTE_ARGS=() + if [ "$PUBLISH" = "true" ]; then + NOTE_ARGS+=(--require-release-notes) + fi node scripts/prepare-public-release.js \ --tag "$RELEASE_TAG" \ --candidate-dir dist/release-candidate \ --output-dir dist/public-release \ - --commit "$GITHUB_SHA" + --commit "$GITHUB_SHA" \ + "${NOTE_ARGS[@]}" - name: Check public bundle before attestation run: | @@ -325,10 +350,19 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.release_tag }} CONFIRMATION: ${{ inputs.confirmation }} + CREATOR_QUALIFICATION: ${{ inputs.creator_qualification }} + QUALIFICATION_REFERENCE: ${{ inputs.qualification_reference }} run: | test "$GITHUB_REF" = "refs/heads/main" test "$CONFIRMATION" = "PUBLISH_UNSIGNED_ALPHA" test "${{ inputs.real_model }}" = "true" + test "$CREATOR_QUALIFICATION" = "NOT_REQUIRED" || test "$CREATOR_QUALIFICATION" = "PASSED_PHYSICAL_MAC" + if [ "$CREATOR_QUALIFICATION" = "PASSED_PHYSICAL_MAC" ]; then + test -n "$QUALIFICATION_REFERENCE" + fi + node scripts/check-published-release.js --validate-qualification \ + --creator-qualification "$CREATOR_QUALIFICATION" \ + --qualification-reference "$QUALIFICATION_REFERENCE" git fetch origin main --tags --force test "$(git rev-parse origin/main)" = "$GITHUB_SHA" git tag --list > "$RUNNER_TEMP/scriptcut-existing-tags.txt" @@ -365,12 +399,42 @@ jobs: dist/public-release/ScriptCut-${RELEASE_TAG}-arm64.dmg.sigstore.json \ dist/public-release/release-manifest.sigstore.json - - name: Verify published tag, prerelease state, assets, and digest + - name: Verify published release and write closure evidence env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.release_tag }} + CREATOR_QUALIFICATION: ${{ inputs.creator_qualification }} + QUALIFICATION_REFERENCE: ${{ inputs.qualification_reference }} run: | - git fetch origin "refs/tags/$RELEASE_TAG" - test "$(git ls-remote origin "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}" | tail -n 1 | awk '{print $1}')" = "$GITHUB_SHA" - gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease,tagName,assets > "$RUNNER_TEMP/published-release.json" - node scripts/check-public-release.js --release-json "$RUNNER_TEMP/published-release.json" --dir dist/public-release + QUALIFICATION_ARGS=(--creator-qualification "$CREATOR_QUALIFICATION") + if [ -n "$QUALIFICATION_REFERENCE" ]; then + QUALIFICATION_ARGS+=(--qualification-reference "$QUALIFICATION_REFERENCE") + fi + node scripts/check-published-release.js \ + --repo "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" \ + --commit "$GITHUB_SHA" \ + --dir dist/public-release \ + --output "$RUNNER_TEMP/release-closure.json" \ + --workflow "$GITHUB_WORKFLOW" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + "${QUALIFICATION_ARGS[@]}" + { + echo "## Published release closure" + echo "- releaseTag: $RELEASE_TAG" + echo "- commit: $GITHUB_SHA" + echo "- DMG SHA-256: $(node -p "require('./dist/public-release/release-manifest.json').artifact.sha256")" + echo "- creator qualification: $CREATOR_QUALIFICATION" + if [ -n "$QUALIFICATION_REFERENCE" ]; then echo "- qualification reference: $QUALIFICATION_REFERENCE"; fi + echo "- release URL: $(node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('$RUNNER_TEMP/release-closure.json','utf8')); console.log(p.release.url)")" + echo "- post-publish verification: PASS" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload release closure evidence + uses: actions/upload-artifact@v4 + with: + name: scriptcut-${{ inputs.release_tag }}-release-closure-evidence + if-no-files-found: error + retention-days: 30 + path: ${{ runner.temp }}/release-closure.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..84abc78 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +### Added + +- Canonical release-note and post-publication closure verification for the public alpha workflow. + +### Changed + +- Release qualification now separates product identity, public release identity, and maintainer evidence. +- Public release preparation can use curated, creator-oriented changes without duplicating them in generator code. + +### Fixed + +- Post-publication release verification now has an explicit reusable check of the live GitHub release state. + +## v0.1.0-alpha.3 + +- First qualified self-contained public macOS Apple Silicon alpha with local baseline Whisper transcription, bundled runtime and FFmpeg, and verifiable GitHub release provenance. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8201289..272d583 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -116,7 +116,40 @@ Use `npm run dist:dir` when you only need an unpacked app bundle for local QA. The dedicated `.github/workflows/release-unsigned.yml` workflow is `workflow_dispatch` only. It builds on a native `macos-14` arm64 runner using the existing `npm run release:rc:arm64` candidate machinery, then stages the exact public DMG, public manifest, notes, checksum, and Sigstore attestation bundles. The resulting app uses an ad-hoc code signature for package integrity; the workflow name and input identifiers retain their existing compatibility names. -Required inputs are `release_tag`, `publish`, `real_model`, and `confirmation`. The tag must be `v-alpha.` and must be greater than every existing alpha tag. `publish=false` is the safe dry-run mode: it may create attestations and a workflow artifact whose name includes `dry-run`, but it cannot create a tag, release, commit, or mutate `main`. Publication additionally requires `publish=true`, `real_model=true`, `confirmation=PUBLISH_UNSIGNED_ALPHA`, the workflow ref to be `main`, and a current `origin/main` equal to the dispatched commit. The publish job has contents write permission only, downloads the already verified artifact, creates a GitHub prerelease with `--prerelease --latest=false`, and verifies the exact tag, asset set, and digest after creation. It never rebuilds during publication. +Required inputs are `release_tag`, `publish`, `real_model`, and `confirmation`. Publication also requires the explicit human `creator_qualification` declaration `NOT_REQUIRED` or `PASSED_PHYSICAL_MAC`; the latter requires a non-empty `qualification_reference`. `NOT_REQUIRED` is valid only when [Release QA](./RELEASE_QA.md) says the physical creator gate is unnecessary. The declaration is not an automated physical test result. The tag must be `v-alpha.` and must be greater than every existing alpha tag. `publish=false` is the safe dry-run mode: it may create attestations and a workflow artifact whose name includes `dry-run`, but it cannot create a tag, release, commit, or mutate `main`. Publication additionally requires `publish=true`, `real_model=true`, `confirmation=PUBLISH_UNSIGNED_ALPHA`, the workflow ref to be `main`, and a current `origin/main` equal to the dispatched commit. The publish job has contents write permission only, downloads the already verified artifact, creates a GitHub prerelease with `--prerelease --latest=false`, and runs the reusable `scripts/check-published-release.js` verifier after creation. It never rebuilds during publication. + +## Changelog and release lifecycle + +`CHANGELOG.md` is the single concise, human-maintained source for meaningful +creator-facing changes. Keep current work under `Unreleased`; do not record +every commit or internal refactor. Before publication, curate the relevant +entries into an exact `## v-alpha.` section in a normal +commit or PR. The public `RELEASE_NOTES.md` generator adds that section under +`What's changed` while retaining the authoritative technical sections. + +The durable lifecycle is: + +```text +Maintain CHANGELOG Unreleased +→ prepare an exact releaseTag section +→ merge to main +→ run the public dry-run +→ complete manual creator qualification when required +→ publish with an explicit qualification declaration +→ verify live GitHub release state +→ retain closure evidence in Actions +``` + +Dry-runs may render `Unreleased` when an exact release section is not present, +and label that content as planned. Publication refuses to proceed without the +exact release section. The workflow never edits `CHANGELOG.md` or commits back +to `main`. + +`release-manifest.json` remains artifact/build provenance. The GitHub tag, +Release, and six public assets remain the published release authority. +`scripts/check-published-release.js` verifies that live state, while +`release-closure.json` records one successful verification and is retained as +workflow evidence only; it is not a public release asset. The closure procedure is a `publish=false` dry-run from current `main`. Run it before any separately authorized public publication; it creates no tag or @@ -158,6 +191,10 @@ Attach: - `dist/public-release/ScriptCut-v-alpha.-arm64.dmg.sigstore.json` - `dist/public-release/release-manifest.sigstore.json` +The public release remains exactly these six assets. Closure evidence is +uploaded to GitHub Actions under a separate evidence artifact with operational +retention; it is not added to the GitHub Release. + The public release manifest uses schema `scriptcut.release.v2` and records the ad-hoc structural signature, Apple Developer ID, and notarization truth, bundled runtime/core/FFmpeg/model provenance, final DMG SHA-256, release tag, source commit, and the DMG attestation reference. The DMG and manifest are each attested with the official GitHub artifact-attestation action; the workflow artifact bundles are retained for independent verification. ## Signing And Notarization diff --git a/docs/RELEASE_QA.md b/docs/RELEASE_QA.md index b55341b..a0d8463 100644 --- a/docs/RELEASE_QA.md +++ b/docs/RELEASE_QA.md @@ -15,6 +15,27 @@ The identity contract is intentionally unchanged: - A developer build, candidate, dry-run, qualified public artifact, and published release are different states. +## Durable release lifecycle + +```text +Maintain CHANGELOG Unreleased +→ prepare an exact release-specific changelog section +→ merge to main +→ run the public dry-run +→ complete manual creator qualification when required +→ publish +→ verify live GitHub release state +→ retain closure evidence +``` + +`CHANGELOG.md` is intentionally lightweight: record meaningful creator-facing +changes, compatibility, security, installation, or release-integrity changes; +do not turn it into an exhaustive commit log. Before publication, curate the +relevant `Unreleased` entries into the exact `releaseTag` section. Public notes +are generated from that source. Dry-runs may use `Unreleased` when the exact +section is not yet present, but publication requires the exact non-empty +section and never mutates the changelog. + ## Gate matrix | Gate | Owner stage | Runner | What it proves | Required | Conditional | Network / model download | Can mutate GitHub | @@ -69,6 +90,14 @@ first-use flow, video open/preview, transcription, or the core export path. Release-system-only changes that do not alter the public artifact or creator runtime may mark creator qualification as not required. +At publication time the maintainer must explicitly declare either +`NOT_REQUIRED` or `PASSED_PHYSICAL_MAC`. `NOT_REQUIRED` is valid only when the +policy above says the physical creator gate is unnecessary. +`PASSED_PHYSICAL_MAC` is a human declaration, not an automated test result, +and requires a durable `qualification_reference` such as an issue, PR/comment +URL, or concise test record identifier. The workflow does not infer this value +from changed filenames. + Physical MPS validation is required only when the change touches Torch/MPS device behavior, Whisper MPS timing compatibility, GPU execution selection, MPS-specific transcription code, or native GPU dependency/runtime behavior. @@ -102,6 +131,13 @@ There is no public npm publisher. Publication remains exclusively owned by candidate bytes, attests them, verifies them on a clean native runner, and publishes only the already verified artifact. +After the release is created, `scripts/check-published-release.js` verifies the +live tag, release, exact six-asset set, server-side asset digests, and exact +release body. The successful publish job writes `release-closure.json` and a +summary containing the release identity, DMG digest, qualification declaration, +and release URL. The closure file is operational workflow evidence, not a +second manifest, trust document, proof of human qualification, or public asset. + ## Trust and mutation invariants The public path remains current-main-only and requires diff --git a/docs/VERIFY_RELEASE.md b/docs/VERIFY_RELEASE.md index 58a3819..6e0ff9f 100644 --- a/docs/VERIFY_RELEASE.md +++ b/docs/VERIFY_RELEASE.md @@ -33,3 +33,22 @@ Replace the example DMG name and `` with the values in `release-mani ## First launch Because the public alpha is ad-hoc-signed but not signed with Apple Developer ID or notarized, macOS may block it. For a DMG downloaded from the official Releases feed, use **System Settings → Privacy & Security → Open Anyway** and confirm the prompt. Do not disable Gatekeeper or remove quarantine attributes. + +## Maintainer publication closure + +Creator verification above establishes artifact integrity and build provenance +from the files in the public release. Maintainers use the separate +`scripts/check-published-release.js` tool after publication to compare live +GitHub state with the already verified local six-file bundle: + +```bash +node scripts/check-published-release.js \ + --repo FernandoAbishai/ScriptCut \ + --tag v0.1.0-alpha.3 \ + --commit \ + --dir dist/public-release \ + --output release-closure.json +``` + +This is post-publication closure evidence, not an additional creator +installation step and not a replacement for `release-manifest.json`. diff --git a/package.json b/package.json index 22b17a8..bd73e35 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "smoke:packaged-transcription": "node scripts/smoke-packaged-transcription.js --arch arm64", "smoke:packaged-optional-capabilities": "node scripts/smoke-packaged-optional-capabilities.js --arch arm64", "smoke:release-metadata": "node scripts/smoke-release-metadata.js", + "smoke:release-notes": "node scripts/smoke-release-notes.js", + "smoke:published-release": "node scripts/smoke-published-release.js", "smoke:release-identity": "node scripts/smoke-release-identity.js", "release:public:prepare": "node scripts/prepare-public-release.js", "check:public-release": "node scripts/check-public-release.js", diff --git a/scripts/check-published-release.js b/scripts/check-published-release.js new file mode 100644 index 0000000..b4dd1f8 --- /dev/null +++ b/scripts/check-published-release.js @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { formatPublicArtifactFilename } = require('./release-identity'); +const { normalizeLineEndings } = require('./release-notes'); + +const root = path.join(__dirname, '..'); +const CLOSURE_SCHEMA = 'scriptcut.release-closure.v1'; +const PUBLIC_ASSET_COUNT = 6; + +function fail(message) { + throw new Error(`Published release verification failed: ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function readJson(filePath, label) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + fail(`could not read ${label}: ${error.message}`); + } +} + +function runGh(args) { + const result = spawnSync('gh', args, { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) { + fail(`gh ${args.join(' ')} failed: ${(result.error?.message || result.stderr || result.stdout || '').trim()}`); + } + return result.stdout; +} + +function ghApi(repo, endpoint) { + return JSON.parse(runGh(['api', `repos/${repo}/${endpoint}`])); +} + +function liveApi() { + return { + getTagRef: (repo, tag) => ghApi(repo, `git/ref/tags/${encodeURIComponent(tag)}`), + getAnnotatedTag: (repo, sha) => ghApi(repo, `git/tags/${sha}`), + getRelease: (repo, tag) => ghApi(repo, `releases/tags/${encodeURIComponent(tag)}`), + }; +} + +function resolveTagCommit(api, repo, tag) { + const ref = api.getTagRef(repo, tag); + assert(ref?.object?.sha && ref.object.type, 'tag reference is missing its object'); + if (ref.object.type === 'commit') return ref.object.sha; + if (ref.object.type !== 'tag') fail(`unexpected tag object type: ${ref.object.type}`); + const annotated = api.getAnnotatedTag(repo, ref.object.sha); + assert(annotated?.object?.sha && annotated.object.type, 'annotated tag target is missing its object'); + if (annotated.object.type !== 'commit') fail(`annotated tag target is not a commit: ${annotated.object.type}`); + return annotated.object.sha; +} + +function expectedAssetNames(manifest) { + return [ + manifest.artifact.filename, + 'SHA256SUMS.txt', + 'release-manifest.json', + 'RELEASE_NOTES.md', + `${manifest.artifact.filename}.sigstore.json`, + 'release-manifest.sigstore.json', + ]; +} + +async function checksumFile(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 }); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +async function readLocalBundle(directory, tag) { + const manifestPath = path.join(directory, 'release-manifest.json'); + const notesPath = path.join(directory, 'RELEASE_NOTES.md'); + const manifest = readJson(manifestPath, 'release-manifest.json'); + assert(manifest.schema === 'scriptcut.release.v2', 'local release manifest schema must remain scriptcut.release.v2'); + assert(manifest.releaseTag === tag, 'local release manifest tag does not match requested tag'); + assert(typeof manifest.artifact?.filename === 'string', 'local release manifest artifact filename is missing'); + assert(manifest.artifact.filename === formatPublicArtifactFilename(tag, manifest.version, 'arm64'), 'local DMG filename does not match the release tag'); + assert(/^[0-9a-f]{64}$/.test(manifest.artifact.sha256), 'local release manifest artifact SHA-256 is missing'); + + const expectedNames = expectedAssetNames(manifest); + const actualNames = fs.readdirSync(directory).filter((name) => fs.statSync(path.join(directory, name)).isFile()).sort(); + assert(actualNames.length === PUBLIC_ASSET_COUNT && expectedNames.every((name) => actualNames.includes(name)), 'local public bundle is not the exact six-file payload'); + + const files = {}; + const digests = {}; + for (const name of expectedNames) { + const filePath = path.join(directory, name); + assert(fs.existsSync(filePath), `local public asset is missing: ${name}`); + files[name] = filePath; + digests[name] = await checksumFile(filePath); + } + assert(digests[manifest.artifact.filename] === manifest.artifact.sha256, 'local DMG digest does not match release-manifest artifact SHA-256'); + const sums = fs.readFileSync(files['SHA256SUMS.txt'], 'utf8'); + assert(sums === `${manifest.artifact.sha256} ${manifest.artifact.filename}\n`, 'local SHA256SUMS.txt does not match the manifest'); + return { manifest, notes: fs.readFileSync(notesPath, 'utf8'), expectedNames, files, digests }; +} + +function releaseFields(release) { + return { + id: release.id, + url: release.html_url || release.url, + tag: release.tag_name || release.tagName, + draft: release.draft ?? release.isDraft, + prerelease: release.prerelease ?? release.isPrerelease, + title: release.name || release.title, + body: release.body, + targetCommit: release.target_commitish ?? release.targetCommitish, + assets: release.assets || [], + }; +} + +function verifyReleaseMetadata(releaseJson, { repo, tag, commit, expectedNames, digests, notes, manifest }) { + const release = releaseFields(releaseJson); + assert(release.tag === tag, 'GitHub Release tag does not match requested release tag'); + assert(release.draft === false, 'GitHub Release must not be a draft'); + assert(release.prerelease === true, 'GitHub Release must be a prerelease'); + assert(release.title === `ScriptCut ${tag}`, 'GitHub Release title is incorrect'); + if (release.targetCommit) assert(release.targetCommit === commit, 'GitHub Release target does not match expected commit'); + assert(typeof release.body === 'string', 'GitHub Release body is missing'); + assert(normalizeLineEndings(release.body) === normalizeLineEndings(notes), 'GitHub Release body does not match local RELEASE_NOTES.md'); + assert(Array.isArray(release.assets) && release.assets.length === PUBLIC_ASSET_COUNT, 'GitHub Release does not have exactly six assets'); + + const names = release.assets.map((asset) => asset.name); + assert(new Set(names).size === names.length, 'GitHub Release contains duplicate asset names'); + assert(names.every((name) => expectedNames.includes(name)) && expectedNames.every((name) => names.includes(name)), 'GitHub Release asset set is not exact'); + + const remoteDigests = {}; + for (const asset of release.assets) { + assert(typeof asset.digest === 'string' && /^sha256:[0-9a-f]{64}$/.test(asset.digest), `GitHub asset digest is missing or invalid: ${asset.name}`); + const digest = asset.digest.slice('sha256:'.length); + remoteDigests[asset.name] = digest; + assert(digest === digests[asset.name], `GitHub asset digest does not match local file: ${asset.name}`); + } + assert(remoteDigests[manifest.artifact.filename] === manifest.artifact.sha256, 'GitHub DMG digest does not match release-manifest artifact SHA-256'); + return { release, remoteDigests }; +} + +function validateQualification(status, reference) { + const normalizedReference = typeof reference === 'string' ? reference.trim() : ''; + if (status === undefined || status === null || status === '') { + assert(!normalizedReference, 'qualification reference requires a creator qualification status'); + return { status: null, reference: null }; + } + assert(status === 'NOT_REQUIRED' || status === 'PASSED_PHYSICAL_MAC', `invalid creator qualification: ${status}`); + if (status === 'PASSED_PHYSICAL_MAC') { + assert(normalizedReference, 'PASSED_PHYSICAL_MAC requires a qualification reference'); + assert(!/[\r\n]/.test(normalizedReference), 'qualification reference must be a single line'); + assert(!/^[/~]|^[A-Za-z]:[\\/]/.test(normalizedReference), 'qualification reference must not be a local filesystem path'); + assert(!/(^|[/\\])(?:Users|private|var[/\\]folders|runner_temp)([/\\]|$)/i.test(normalizedReference), 'qualification reference must not leak a private runner path'); + } + return { status, reference: normalizedReference || null }; +} + +async function verifyPublishedRelease({ + repo, + tag, + commit, + dir, + output, + api = liveApi(), + workflow = null, + runId = null, + runAttempt = null, + creatorQualification, + qualificationReference, + verifiedAt = new Date().toISOString(), +} = {}) { + assert(typeof repo === 'string' && repo.includes('/'), 'repo is required'); + assert(typeof tag === 'string' && tag, 'tag is required'); + assert(typeof commit === 'string' && /^[0-9a-f]{40}$/.test(commit), 'commit must be a full SHA-1'); + const directory = path.resolve(dir || path.join(root, 'dist', 'public-release')); + const local = await readLocalBundle(directory, tag); + const tagCommit = resolveTagCommit(api, repo, tag); + assert(tagCommit === commit, 'GitHub tag does not resolve to expected commit'); + const releaseJson = api.getRelease(repo, tag); + const published = verifyReleaseMetadata(releaseJson, { + repo, + tag, + commit, + expectedNames: local.expectedNames, + digests: local.digests, + notes: local.notes, + manifest: local.manifest, + }); + const qualification = validateQualification(creatorQualification, qualificationReference); + const closure = { + schema: CLOSURE_SCHEMA, + repository: repo, + releaseTag: tag, + commit, + workflow, + runId, + runAttempt, + verifiedAt, + creatorQualification: qualification, + release: { + id: published.release.id, + url: published.release.url, + draft: published.release.draft, + prerelease: published.release.prerelease, + title: published.release.title, + targetCommit: published.release.targetCommit || null, + }, + verification: { + tagCommit, + releaseState: true, + exactAssets: true, + assetDigests: published.remoteDigests, + releaseNotes: true, + }, + artifact: { + filename: local.manifest.artifact.filename, + sha256: local.manifest.artifact.sha256, + bytes: local.manifest.artifact.bytes, + }, + }; + if (output) { + fs.writeFileSync(path.resolve(output), `${JSON.stringify(closure, null, 2)}\n`, 'utf8'); + } + return closure; +} + +async function main() { + const creatorQualification = optionValue('--creator-qualification'); + const qualificationReference = optionValue('--qualification-reference'); + if (process.argv.includes('--validate-qualification')) { + validateQualification(creatorQualification, qualificationReference); + console.log('Creator qualification declaration is valid.'); + return; + } + const closure = await verifyPublishedRelease({ + repo: optionValue('--repo') || process.env.GITHUB_REPOSITORY, + tag: optionValue('--tag'), + commit: optionValue('--commit') || process.env.GITHUB_SHA, + dir: optionValue('--dir'), + output: optionValue('--output'), + workflow: optionValue('--workflow') || process.env.GITHUB_WORKFLOW || null, + runId: optionValue('--run-id') || process.env.GITHUB_RUN_ID || null, + runAttempt: optionValue('--run-attempt') || process.env.GITHUB_RUN_ATTEMPT || null, + creatorQualification, + qualificationReference, + }); + console.log(`Published release verified: ${closure.releaseTag} -> ${closure.commit}`); + console.log(`DMG SHA-256: ${closure.artifact.sha256}`); + if (optionValue('--output')) console.log(`Closure evidence: ${path.resolve(optionValue('--output'))}`); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} + +module.exports = { + CLOSURE_SCHEMA, + PUBLIC_ASSET_COUNT, + resolveTagCommit, + validateQualification, + verifyPublishedRelease, +}; diff --git a/scripts/check-release-workflow.js b/scripts/check-release-workflow.js index 5d94763..1a908ea 100644 --- a/scripts/check-release-workflow.js +++ b/scripts/check-release-workflow.js @@ -50,6 +50,8 @@ function validateSourceCiGates(text) { const requiredCommands = [ 'npm run smoke:release-identity', 'npm run smoke:release-metadata', + 'npm run smoke:release-notes', + 'npm run smoke:published-release', 'npm run smoke:public-release', 'npm run smoke:release-workflow', 'npm run smoke:runtime-contract', @@ -113,7 +115,7 @@ function validateWorkflowText(text, { candidateWorkflowText = fs.readFileSync(ci const trigger = text.slice(0, jobsStart); assert(/\non:\s*\n\s+workflow_dispatch:\s*\n/.test(trigger), 'workflow_dispatch-only trigger is missing'); assert(!/^\s{2}(?:push|pull_request|schedule):/m.test(trigger), 'automatic trigger found'); - ['release_tag', 'publish', 'real_model', 'confirmation'].forEach((input) => assert(new RegExp(`^\\s{6}${input}:`, 'm').test(trigger), `workflow input is missing: ${input}`)); + ['release_tag', 'publish', 'real_model', 'confirmation', 'creator_qualification', 'qualification_reference'].forEach((input) => assert(new RegExp(`^\\s{6}${input}:`, 'm').test(trigger), `workflow input is missing: ${input}`)); assert(/actions\/attest@v4/g.test(text) && (text.match(/actions\/attest@v4/g) || []).length === 2, 'workflow must attest exactly the DMG and manifest'); const build = jobBlock(text, 'build'); @@ -138,6 +140,8 @@ function validateWorkflowText(text, { candidateWorkflowText = fs.readFileSync(ci validateOpenFileGuard(candidateWorkflowText, 'ci.yml', 'Build and verify ad-hoc self-contained release candidate'); validateIconBuildPrerequisites(text, 'release-unsigned.yml', 'Install release build prerequisites'); validateIconBuildPrerequisites(candidateWorkflowText, 'ci.yml', 'Install release build prerequisites'); + const prepareNotes = stepBlock(build, 'Prepare public release bundle'); + assert(/PUBLISH/.test(prepareNotes) && /--require-release-notes/.test(prepareNotes), 'publication build must require exact curated release notes'); assert(/runs-on:\s+macos-14/.test(clean), 'clean verification runner must be macos-14'); assert(/uname -m[\s\S]*?arm64/.test(clean) && /node -p process\.arch[\s\S]*?arm64/.test(clean), 'clean runner must verify native macOS arm64'); @@ -162,6 +166,9 @@ function validateWorkflowText(text, { candidateWorkflowText = fs.readFileSync(ci assert(!/id-token:\s+write|attestations:\s+write|artifact-metadata:\s+write/.test(publish), 'publish job has unnecessary attestation permissions'); assert(/PUBLISH_UNSIGNED_ALPHA/.test(publish), 'publication confirmation gate is missing'); assert(/real_model/.test(publish), 'publication real-model gate is missing'); + assert(/creator_qualification/.test(publish) && /NOT_REQUIRED/.test(publish) && /PASSED_PHYSICAL_MAC/.test(publish), 'publication creator qualification declaration is missing'); + assert(/QUALIFICATION_REFERENCE/.test(publish) && /CREATOR_QUALIFICATION[\s\S]*?PASSED_PHYSICAL_MAC[\s\S]*?test -n "\$QUALIFICATION_REFERENCE"/.test(publish), 'physical qualification reference gate is missing'); + assert((publish.match(/--validate-qualification/g) || []).length >= 1, 'publication qualification safety validation is missing'); assert(/refs\/heads\/main/.test(publish) && /origin\/main/.test(publish) && /GITHUB_SHA/.test(publish), 'publication main-current gate is missing'); assert(/--prerelease/.test(publish) && /--latest=false/.test(publish), 'publication prerelease/latest semantics are missing'); assert(/actions\/download-artifact@v4/.test(publish), 'publish job must download verified output'); @@ -169,6 +176,22 @@ function validateWorkflowText(text, { candidateWorkflowText = fs.readFileSync(ci assert(!/release:rc:arm64|electron-builder/.test(publish), 'publish job must not rebuild the artifact'); const createRelease = stepBlock(publish, 'Create exact GitHub prerelease without rebuilding'); assert(/git fetch origin main --force[\s\S]*test "\$\(git rev-parse origin\/main\)" = "\$GITHUB_SHA"[\s\S]*gh release create/.test(createRelease), 'release mutation step lacks an immediate main-current recheck'); + [ + 'dist/public-release/ScriptCut-${RELEASE_TAG}-arm64.dmg', + 'dist/public-release/SHA256SUMS.txt', + 'dist/public-release/release-manifest.json', + 'dist/public-release/RELEASE_NOTES.md', + 'dist/public-release/ScriptCut-${RELEASE_TAG}-arm64.dmg.sigstore.json', + 'dist/public-release/release-manifest.sigstore.json', + ].forEach((asset) => assert(createRelease.includes(asset), `public release asset is missing from creation command: ${asset}`)); + const closure = stepBlock(publish, 'Verify published release and write closure evidence'); + const createIndex = publish.indexOf('- name: Create exact GitHub prerelease without rebuilding'); + const closureIndex = publish.indexOf('- name: Verify published release and write closure evidence'); + assert(createIndex >= 0 && closureIndex > createIndex, 'closure verification must run after release creation'); + assert(/scripts\/check-published-release\.js/.test(closure) && /--output/.test(closure), 'publish job must run the reusable closure verifier'); + assert(/GITHUB_STEP_SUMMARY/.test(closure) && /DMG SHA-256/.test(closure) && /post-publish verification: PASS/.test(closure), 'publish job summary must record closure evidence'); + assert(/scriptcut-.*release-closure-evidence/.test(publish) && /retention-days/.test(publish), 'closure evidence artifact upload is missing'); + assert(!/release-closure/.test(createRelease), 'closure evidence must not become a public release asset'); assert(!/(APPLE_[A-Z_]+|CSC_[A-Z_]+|Developer ID|notarytool|private key|PUBLISH_UNSIGNED_ALPHA.*secret)/i.test(text), 'Apple credential or private-signing dependency found'); return true; diff --git a/scripts/prepare-public-release.js b/scripts/prepare-public-release.js index 1c9ab38..65b55f8 100644 --- a/scripts/prepare-public-release.js +++ b/scripts/prepare-public-release.js @@ -11,6 +11,7 @@ const { readProductVersion, validateAlphaReleaseTag, } = require('./release-identity'); +const { selectReleaseNotes } = require('./release-notes'); const root = path.join(__dirname, '..'); const packagePath = path.join(root, 'package.json'); @@ -143,10 +144,22 @@ function publicManifest({ pkg, tag, commit, artifact, candidate, dmgAttestation }; } -function publicNotes(manifest) { +function publicNotes(manifest, { changelogPath, publicationNotesRequired = false } = {}) { const artifact = manifest.artifact.filename; + const curated = selectReleaseNotes({ + releaseTag: manifest.releaseTag, + changelogPath, + publicationNotesRequired, + }); + const dryRunLabel = curated.source === 'Unreleased' + ? `> Dry-run / planned release-note content from \`CHANGELOG.md\` → \`Unreleased\`; it is not part of a published release history.\n\n` + : ''; return `# ScriptCut ${manifest.releaseTag} alpha +## What's changed + +${dryRunLabel}${curated.markdown} + ## ScriptCut alpha status This is a public prerelease alpha for creator validation. It is provided from the official ScriptCut GitHub repository and uses an ad-hoc code signature for package integrity. @@ -222,6 +235,8 @@ async function preparePublicRelease(options = {}) { const productVersion = readProductVersion(packagePath); if (pkg.version !== productVersion) fail('package version does not match canonical productVersion'); const tag = options.tag || optionValue('--tag'); + const publicationNotesRequired = options.publicationNotesRequired === true || hasOption('--require-release-notes'); + const changelogPath = options.changelogPath || optionValue('--changelog'); const existingTags = options.existingTags || readExistingTags(optionValue('--existing-tags-file')); const tagInfo = validateReleaseTag(tag, productVersion, existingTags); const candidateDir = path.resolve(options.candidateDir || optionValue('--candidate-dir') || path.join(root, 'dist', 'release-candidate')); @@ -267,7 +282,7 @@ async function preparePublicRelease(options = {}) { : null; const manifest = publicManifest({ pkg, tag: tagInfo.releaseTag, commit, artifact, candidate: candidateManifest, dmgAttestation: attestation }); fs.writeFileSync(path.join(outputDir, 'release-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); - fs.writeFileSync(path.join(outputDir, 'RELEASE_NOTES.md'), publicNotes(manifest), 'utf8'); + fs.writeFileSync(path.join(outputDir, 'RELEASE_NOTES.md'), publicNotes(manifest, { changelogPath, publicationNotesRequired }), 'utf8'); fs.writeFileSync(path.join(outputDir, 'SHA256SUMS.txt'), `${artifact.sha256} ${artifact.filename}\n`, 'utf8'); return { tagInfo, outputDir, publicDmgPath, manifestPath: path.join(outputDir, 'release-manifest.json'), manifest }; @@ -288,6 +303,8 @@ async function main() { dmgAttestationUrl: optionValue('--dmg-attestation-url'), dmgAttestationId: optionValue('--dmg-attestation-id'), dmgAttestationBundle: optionValue('--dmg-attestation-bundle'), + publicationNotesRequired: hasOption('--require-release-notes'), + changelogPath: optionValue('--changelog'), }); console.log(`Public release prepared: ${path.relative(root, result.outputDir)}`); console.log(`Public DMG: ${path.relative(root, result.publicDmgPath)}`); diff --git a/scripts/release-notes.js b/scripts/release-notes.js new file mode 100644 index 0000000..af1087c --- /dev/null +++ b/scripts/release-notes.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const DEFAULT_CHANGELOG_PATH = path.join(root, 'CHANGELOG.md'); +const RELEASE_TAG_PATTERN = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-alpha\.[1-9]\d*$/; + +function fail(message) { + throw new Error(`Release notes validation failed: ${message}`); +} + +function normalizeLineEndings(text) { + return String(text).replace(/\r\n?/g, '\n'); +} + +function readChangelog(changelogPath = DEFAULT_CHANGELOG_PATH) { + try { + return normalizeLineEndings(fs.readFileSync(changelogPath, 'utf8')); + } catch (error) { + fail(`could not read ${path.relative(root, changelogPath)}: ${error.message}`); + } +} + +function parseChangelog(text) { + const lines = normalizeLineEndings(text).split('\n'); + const sections = new Map(); + let titleSeen = false; + let current = null; + + lines.forEach((line, index) => { + if (index === 0 && line === '# Changelog') { + titleSeen = true; + return; + } + const heading = /^(#{1,6})[ \t]+(.*?)[ \t]*$/.exec(line); + if (heading && heading[1] === '##') { + const name = heading[2]; + if (name !== 'Unreleased' && !RELEASE_TAG_PATTERN.test(name)) { + fail(`malformed release section heading on line ${index + 1}: ${line}`); + } + if (sections.has(name)) fail(`duplicate release section: ${name}`); + current = { name, line: index + 1, lines: [] }; + sections.set(name, current); + return; + } + if (heading && heading[1] === '#') { + if (titleSeen) fail(`unexpected top-level heading on line ${index + 1}: ${line}`); + fail(`changelog must begin with # Changelog (found ${line})`); + } + if (heading && heading[1].length > 2 && current === null) { + fail(`content heading appears before a release section on line ${index + 1}`); + } + if (current) current.lines.push(line); + }); + + if (!titleSeen) fail('missing # Changelog heading'); + if (!sections.has('Unreleased')) fail('missing Unreleased section'); + return sections; +} + +function sectionMarkdown(section) { + if (!section) return ''; + return section.lines.join('\n').trim(); +} + +function hasMeaningfulContent(section) { + return Boolean(section?.lines.some((line) => { + const trimmed = line.trim(); + return trimmed && !/^#{1,6}(?:[ \t]+|$)/.test(trimmed); + })); +} + +function selectReleaseNotes({ releaseTag, publicationNotesRequired = false, changelogPath = DEFAULT_CHANGELOG_PATH } = {}) { + if (!releaseTag || typeof releaseTag !== 'string') fail('releaseTag is required'); + const sections = parseChangelog(readChangelog(changelogPath)); + const exact = sections.get(releaseTag); + const unreleased = sections.get('Unreleased'); + const exactMarkdown = sectionMarkdown(exact); + + if (publicationNotesRequired) { + if (!exact || !hasMeaningfulContent(exact)) fail(`publication changelog section is empty or heading-only: ${releaseTag}`); + return { releaseTag, source: releaseTag, markdown: exactMarkdown, publicationNotesRequired: true }; + } + + if (exact && hasMeaningfulContent(exact)) { + return { releaseTag, source: releaseTag, markdown: exactMarkdown, publicationNotesRequired: false }; + } + const unreleasedMarkdown = sectionMarkdown(unreleased); + if (!hasMeaningfulContent(unreleased)) fail('Unreleased changelog section is empty or heading-only'); + return { releaseTag, source: 'Unreleased', markdown: unreleasedMarkdown, publicationNotesRequired: false }; +} + +module.exports = { + DEFAULT_CHANGELOG_PATH, + normalizeLineEndings, + parseChangelog, + hasMeaningfulContent, + selectReleaseNotes, + sectionMarkdown, +}; diff --git a/scripts/smoke-published-release.js b/scripts/smoke-published-release.js new file mode 100644 index 0000000..763cc45 --- /dev/null +++ b/scripts/smoke-published-release.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { CLOSURE_SCHEMA, validateQualification, verifyPublishedRelease } = require('./check-published-release'); + +function fail(message) { + throw new Error(`Published release verifier smoke failed: ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +async function expectFailure(callback, label) { + try { + await callback(); + } catch (_error) { + return; + } + fail(`${label} was accepted`); +} + +function digest(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function fixtureRoot() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptcut-published-release-')); + const tag = 'v0.1.0-alpha.4'; + const commit = 'a'.repeat(40); + const dmg = `ScriptCut-${tag}-arm64.dmg`; + const contents = { + [dmg]: Buffer.from('DMG fixture\n'), + 'SHA256SUMS.txt': null, + 'release-manifest.json': null, + 'RELEASE_NOTES.md': Buffer.from('# ScriptCut v0.1.0-alpha.4 alpha\n\n## What\'s changed\n\n- Exact.\n'), + [`${dmg}.sigstore.json`]: Buffer.from('{"subject":"dmg"}\n'), + 'release-manifest.sigstore.json': Buffer.from('{"subject":"manifest"}\n'), + }; + contents['release-manifest.json'] = Buffer.from(`${JSON.stringify({ + schema: 'scriptcut.release.v2', + version: '0.1.0', + releaseTag: tag, + artifact: { filename: dmg, sha256: digest(contents[dmg]), bytes: contents[dmg].length }, + }, null, 2)}\n`); + contents['SHA256SUMS.txt'] = Buffer.from(`${digest(contents[dmg])} ${dmg}\n`); + for (const [name, value] of Object.entries(contents)) fs.writeFileSync(path.join(root, name), value); + return { root, tag, commit, contents }; +} + +function makeApi(fixture, { annotated = false } = {}) { + const assets = Object.entries(fixture.contents).map(([name, content]) => ({ name, digest: `sha256:${digest(content)}` })); + const release = { + id: 42, + html_url: 'https://github.com/FernandoAbishai/ScriptCut/releases/tag/v0.1.0-alpha.4', + tag_name: fixture.tag, + draft: false, + prerelease: true, + name: `ScriptCut ${fixture.tag}`, + body: fixture.contents['RELEASE_NOTES.md'].toString('utf8'), + target_commitish: fixture.commit, + assets, + }; + return { + getTagRef: () => annotated + ? { object: { type: 'tag', sha: 'b'.repeat(40) } } + : { object: { type: 'commit', sha: fixture.commit } }, + getAnnotatedTag: () => ({ object: { type: 'commit', sha: fixture.commit } }), + getRelease: () => release, + }; +} + +async function main() { + const fixture = fixtureRoot(); + try { + assert(validateQualification('NOT_REQUIRED', '').status === 'NOT_REQUIRED', 'NOT_REQUIRED qualification was rejected'); + assert(validateQualification('PASSED_PHYSICAL_MAC', 'https://github.com/FernandoAbishai/ScriptCut/issues/1').reference.startsWith('https://'), 'durable qualification reference was rejected'); + await expectFailure(() => validateQualification('PASSED_PHYSICAL_MAC', ''), 'qualification without reference'); + await expectFailure(() => validateQualification('PASSED_PHYSICAL_MAC', '/Users/private/test'), 'private path qualification reference'); + const base = { repo: 'FernandoAbishai/ScriptCut', tag: fixture.tag, commit: fixture.commit, dir: fixture.root, verifiedAt: '2026-08-13T00:00:00.000Z' }; + const lightweight = await verifyPublishedRelease({ ...base, api: makeApi(fixture) }); + assert(lightweight.schema === CLOSURE_SCHEMA, 'closure schema is incorrect'); + assert(lightweight.verification.tagCommit === fixture.commit, 'lightweight tag did not resolve'); + const annotated = await verifyPublishedRelease({ ...base, api: makeApi(fixture, { annotated: true }) }); + assert(annotated.verification.releaseNotes === true, 'annotated tag verification did not complete'); + + await expectFailure(() => verifyPublishedRelease({ ...base, commit: 'c'.repeat(40), api: makeApi(fixture) }), 'wrong tag commit'); + await expectFailure(() => verifyPublishedRelease({ ...base, api: { ...makeApi(fixture), getRelease: () => { throw new Error('missing release'); } } }), 'missing release'); + await expectFailure(() => verifyPublishedRelease({ ...base, api: { ...makeApi(fixture), getRelease: () => ({ ...makeApi(fixture).getRelease(), draft: true }) } }), 'draft release'); + await expectFailure(() => verifyPublishedRelease({ ...base, api: { ...makeApi(fixture), getRelease: () => ({ ...makeApi(fixture).getRelease(), prerelease: false }) } }), 'non-prerelease release'); + await expectFailure(() => verifyPublishedRelease({ ...base, api: { ...makeApi(fixture), getRelease: () => ({ ...makeApi(fixture).getRelease(), assets: makeApi(fixture).getRelease().assets.slice(1) }) } }), 'missing asset'); + await expectFailure(() => verifyPublishedRelease({ ...base, api: { ...makeApi(fixture), getRelease: () => ({ ...makeApi(fixture).getRelease(), assets: [...makeApi(fixture).getRelease().assets, { name: 'unexpected.txt', digest: `sha256:${'0'.repeat(64)}` }] }) } }), 'extra asset'); + await expectFailure(() => verifyPublishedRelease({ ...base, api: { ...makeApi(fixture), getRelease: () => ({ ...makeApi(fixture).getRelease(), assets: makeApi(fixture).getRelease().assets.map((asset) => asset.name === fixture.tag ? asset : asset.name === 'SHA256SUMS.txt' ? { ...asset, digest: `sha256:${'0'.repeat(64)}` } : asset) }) } }), 'digest mismatch'); + await expectFailure(() => verifyPublishedRelease({ ...base, api: { ...makeApi(fixture), getRelease: () => ({ ...makeApi(fixture).getRelease(), body: 'wrong body\n' }) } }), 'notes/body mismatch'); + const output = path.join(fixture.root, 'release-closure.json'); + const closure = await verifyPublishedRelease({ ...base, api: makeApi(fixture), output, creatorQualification: 'NOT_REQUIRED' }); + assert(JSON.parse(fs.readFileSync(output, 'utf8')).artifact.sha256 === closure.artifact.sha256, 'closure output was not written'); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + console.log('Published release verifier passed lightweight, annotated, negative, digest, notes, and exact-six-asset fixture checks.'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/smoke-release-notes.js b/scripts/smoke-release-notes.js new file mode 100644 index 0000000..1b8b4bb --- /dev/null +++ b/scripts/smoke-release-notes.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { selectReleaseNotes, parseChangelog } = require('./release-notes'); +const { preparePublicRelease } = require('./prepare-public-release'); + +const root = path.join(__dirname, '..'); + +function fail(message) { + throw new Error(`Release notes smoke failed: ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function expectFailure(callback, label) { + try { + callback(); + } catch (_error) { + return; + } + fail(`${label} was accepted`); +} + +async function expectFailureAsync(callback, label) { + try { + await callback(); + } catch (_error) { + return; + } + fail(`${label} was accepted`); +} + +function writeChangelog(directory, content) { + const filePath = path.join(directory, 'CHANGELOG.md'); + fs.writeFileSync(filePath, content, 'utf8'); + return filePath; +} + +function writeCandidate(directory) { + const candidateDir = path.join(directory, 'candidate'); + fs.mkdirSync(candidateDir, { recursive: true }); + const content = Buffer.from('release notes fixture dmg\n'); + const filename = 'ScriptCut-0.1.0-arm64.dmg'; + fs.writeFileSync(path.join(candidateDir, filename), content); + const crypto = require('crypto'); + const sha256 = crypto.createHash('sha256').update(content).digest('hex'); + fs.writeFileSync(path.join(candidateDir, 'release-manifest.json'), `${JSON.stringify({ + schema: 'scriptcut.release.v1', + version: '0.1.0', + signed: false, + notarized: false, + artifact: { filename, bytes: content.length, sha256 }, + runtime: { + mode: 'packaged-bundled', + pythonSource: 'bundled', + target: { platform: 'darwin', arch: 'arm64' }, + schema: 'scriptcut.runtime.v1', + pythonVersion: '3.11.15', + pythonBuild: '20260807', + manifestSha256: '1'.repeat(64), + }, + coreInventorySha256: '2'.repeat(64), + codeSignature: { type: 'ad-hoc', structurallyValid: true, hardenedRuntime: false }, + ffmpeg: { platform: 'darwin', architecture: 'arm64', manifestSha256: '3'.repeat(64) }, + model: { id: 'whisper-base', revision: '4'.repeat(64), expectedBytes: 10, sha256: '4'.repeat(64), manifestSha256: '5'.repeat(64), embedded: false }, + }, null, 2)}\n`, 'utf8'); + return candidateDir; +} + +async function main() { + const changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); + const sections = parseChangelog(changelog); + assert(sections.has('Unreleased'), 'repository changelog lacks Unreleased'); + assert(sections.has('v0.1.0-alpha.3'), 'repository changelog lacks alpha.3 history'); + assert(selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.4' }).source === 'Unreleased', 'dry-run did not select Unreleased'); + assert(selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.3' }).source === 'v0.1.0-alpha.3', 'exact release section did not win'); + expectFailure(() => selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.4', publicationNotesRequired: true }), 'missing exact publication section'); + + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptcut-release-notes-')); + try { + const fixture = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n### Added\n\n- Planned.\n\n## v0.1.0-alpha.4\n\n- Exact.\n'); + assert(selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.4', changelogPath: fixture }).markdown.includes('Exact.'), 'exact fixture section was not selected'); + expectFailure(() => selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.5', publicationNotesRequired: true, changelogPath: fixture }), 'publication missing section'); + + const duplicate = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n- One.\n\n## v0.1.0-alpha.4\n\n- First.\n\n## v0.1.0-alpha.4\n\n- Second.\n'); + expectFailure(() => selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.4', changelogPath: duplicate }), 'duplicate release section'); + const malformed = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n- One.\n\n## v0.1.0-alpha.x\n\n- Bad.\n'); + expectFailure(() => selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.4', changelogPath: malformed }), 'malformed release heading'); + const empty = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n- One.\n\n## v0.1.0-alpha.4\n\n'); + expectFailure(() => selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.4', publicationNotesRequired: true, changelogPath: empty }), 'empty exact release section'); + + const headingOnlyExact = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n- Planned.\n\n## v0.1.0-alpha.4\n\n### Added\n'); + expectFailure(() => selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.4', publicationNotesRequired: true, changelogPath: headingOnlyExact }), 'heading-only exact publication section'); + + const headingOnlyUnreleased = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n### Fixed\n'); + expectFailure(() => selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.5', changelogPath: headingOnlyUnreleased }), 'heading-only Unreleased section'); + + const headingAndBullet = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n### Fixed\n\n- A real fix.\n'); + assert(selectReleaseNotes({ releaseTag: 'v0.1.0-alpha.5', changelogPath: headingAndBullet }).markdown.includes('- A real fix.'), 'subsection heading plus bullet was rejected'); + + const plannedFixture = writeChangelog(fixtureRoot, '# Changelog\n\n## Unreleased\n\n### Added\n\n- Planned.\n\n'); + const candidateDir = writeCandidate(fixtureRoot); + const outputDir = path.join(fixtureRoot, 'public'); + const result = await preparePublicRelease({ + tag: 'v0.1.0-alpha.4', + existingTags: ['v0.1.0-alpha.1', 'v0.1.0-alpha.2', 'v0.1.0-alpha.3'], + candidateDir, + outputDir, + commit: 'a'.repeat(40), + changelogPath: plannedFixture, + }); + const notes = fs.readFileSync(path.join(result.outputDir, 'RELEASE_NOTES.md'), 'utf8'); + assert(notes.includes("## What's changed"), 'generated notes lack curated section'); + assert(notes.includes('Dry-run / planned release-note content'), 'Unreleased dry-run content is not labeled'); + assert(notes.includes('Planned.'), 'generated notes omit Unreleased content'); + await expectFailureAsync(() => preparePublicRelease({ + tag: 'v0.1.0-alpha.5', + existingTags: ['v0.1.0-alpha.1', 'v0.1.0-alpha.2', 'v0.1.0-alpha.3'], + candidateDir, + outputDir: path.join(fixtureRoot, 'publication'), + commit: 'a'.repeat(40), + changelogPath: plannedFixture, + publicationNotesRequired: true, + }), 'publication without exact release notes'); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + console.log('Release changelog parser, selection, dry-run, publication, and generated-note checks passed.'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +});