diff --git a/.github/workflows/release-prepare-trigger.yml b/.github/workflows/release-prepare-trigger.yml new file mode 100644 index 0000000..cfd4783 --- /dev/null +++ b/.github/workflows/release-prepare-trigger.yml @@ -0,0 +1,49 @@ +name: Trigger release preparation + +on: + workflow_run: + workflows: ["Test"] + types: [completed] + +permissions: {} + +concurrency: + group: release-prepare-trigger + cancel-in-progress: false + +jobs: + dispatch: + name: Dispatch tested master + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'master' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Dispatch exact current master + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + with: + script: | + const testedSha = process.env.TESTED_SHA; + if (!/^[0-9a-f]{40}$/.test(testedSha)) { + throw new Error(`Invalid tested SHA: ${testedSha}`); + } + const { owner, repo } = context.repo; + const master = await github.rest.git.getRef({ owner, repo, ref: 'heads/master' }); + if (master.data.object.sha !== testedSha) { + core.notice(`Skipping stale tested SHA ${testedSha}; master is ${master.data.object.sha}.`); + return; + } + await github.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: 'release-prepare.yml', + ref: 'master', + inputs: { targetSha: testedSha }, + }); + core.notice(`Dispatched release preparation for tested master ${testedSha}.`); diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..8e9a3f3 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,409 @@ +name: Prepare release + +on: + workflow_dispatch: + inputs: + targetSha: + description: "Current master SHA to plan from (defaults to master)" + required: false + type: string + +permissions: {} + +concurrency: + group: release-prepare + cancel-in-progress: false + +jobs: + plan: + name: Plan release + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + artifact-name: ${{ steps.plan.outputs.artifact-name }} + base-sha: ${{ steps.plan.outputs.base-sha }} + release: ${{ steps.plan.outputs.release || 'false' }} + version: ${{ steps.plan.outputs.version }} + steps: + - name: Resolve target SHA + id: target + env: + DISPATCH_SHA: ${{ inputs.targetSha }} + run: | + TARGET_SHA="$DISPATCH_SHA" + if [[ -z "$TARGET_SHA" ]]; then + TARGET_SHA="${{ github.sha }}" + fi + if [[ ! "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "target SHA must be a full commit SHA" >&2 + exit 1 + fi + echo "sha=$TARGET_SHA" >> "$GITHUB_OUTPUT" + + - name: Checkout tested commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.target.outputs.sha }} + + - name: Confirm current master + id: current + env: + TARGET_SHA: ${{ steps.target.outputs.sha }} + run: | + git fetch --no-tags origin master + MASTER_SHA="$(git rev-parse origin/master)" + if [[ "$TARGET_SHA" != "$MASTER_SHA" ]]; then + echo "Skipping stale tested SHA $TARGET_SHA; master is $MASTER_SHA" + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + echo "value=true" >> "$GITHUB_OUTPUT" + + - name: Classify current commit + id: classify + if: steps.current.outputs.value == 'true' + run: | + if [[ "$(git log -1 --format=%s)" =~ ^release\(version\):\ Release\ ]]; then + echo "release-commit=true" >> "$GITHUB_OUTPUT" + else + echo "release-commit=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify previous release is public + if: >- + steps.current.outputs.value == 'true' && + steps.classify.outputs.release-commit == 'false' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const fs = require('node:fs'); + + const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const version = packageJson.version; + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + throw new Error(`Invalid current package version: ${version}`); + } + const { owner, repo } = context.repo; + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + const matches = releases.filter((release) => release.tag_name === version); + if (matches.length !== 1 || matches[0].draft || matches[0].prerelease || + !matches[0].published_at) { + throw new Error( + `Current version ${version} does not have exactly one published stable GitHub release. ` + + 'Recover that release before planning another version.', + ); + } + + - name: Setup Node + if: >- + steps.current.outputs.value == 'true' && + steps.classify.outputs.release-commit == 'false' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: ".nvmrc" + package-manager-cache: false + + - name: Install planning dependencies + if: >- + steps.current.outputs.value == 'true' && + steps.classify.outputs.release-commit == 'false' + run: npm ci --ignore-scripts + + - name: Compute release plan + id: plan + if: >- + steps.current.outputs.value == 'true' && + steps.classify.outputs.release-commit == 'false' + env: + TARGET_SHA: ${{ steps.target.outputs.sha }} + run: | + PLAN="$RUNNER_TEMP/release-plan.json" + NOTES="$RUNNER_TEMP/release-notes.md" + node scripts/release-plan.mjs --cwd . --output "$PLAN" --notes-output "$NOTES" + test "$(jq -r '.baseSha' "$PLAN")" = "$TARGET_SHA" + RELEASE="$(jq -r '.release' "$PLAN")" + echo "release=$RELEASE" >> "$GITHUB_OUTPUT" + echo "base-sha=$TARGET_SHA" >> "$GITHUB_OUTPUT" + if [[ "$RELEASE" != "true" ]]; then + exit 0 + fi + + VERSION="$(jq -r '.nextVersion' "$PLAN")" + CURRENT_VERSION="$(node -p 'require("./package.json").version')" + PREVIOUS_VERSION="$(jq -r '.previousVersion' "$PLAN")" + test "$CURRENT_VERSION" = "$PREVIOUS_VERSION" + if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Invalid planned version: $VERSION" >&2 + exit 1 + fi + OUTPUT="$RUNNER_TEMP/release-version-files" + node scripts/release-contract.mjs materialize \ + --root . \ + --out "$OUTPUT" \ + --version "$VERSION" \ + --base-sha "$TARGET_SHA" + ARTIFACT_NAME="release-version-files-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Upload version-file plan + if: steps.plan.outputs.release == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.plan.outputs.artifact-name }} + path: ${{ runner.temp }}/release-version-files + if-no-files-found: error + retention-days: 1 + + open-pr: + name: Open release PR + needs: plan + if: needs.plan.outputs.release == 'true' + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + pull-requests: write + steps: + - name: Download version-file plan + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.plan.outputs.artifact-name }} + path: release-version-files + + - name: Create release branch and PR + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + BASE_SHA: ${{ needs.plan.outputs.base-sha }} + VERSION: ${{ needs.plan.outputs.version }} + with: + script: | + const crypto = require('node:crypto'); + const fs = require('node:fs'); + const path = require('node:path'); + + const baseSha = process.env.BASE_SHA; + const version = process.env.VERSION; + const expectedFiles = ['manifest.json', 'package-lock.json', 'package.json', 'versions.json']; + const root = path.resolve('release-version-files'); + const metadataPath = path.join(root, 'release-version-files.json'); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (!/^[0-9a-f]{40}$/.test(baseSha) || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + throw new Error('Invalid release plan identity.'); + } + if (metadata.schemaVersion !== 1 || metadata.baseSha !== baseSha || metadata.version !== version) { + throw new Error('Release artifact metadata does not match workflow outputs.'); + } + if (!Array.isArray(metadata.files) || metadata.files.length !== expectedFiles.length) { + throw new Error('Release artifact metadata has an invalid file list.'); + } + + const fileEntries = []; + for (const name of expectedFiles) { + const entry = metadata.files.find((candidate) => candidate.name === name); + const filePath = path.join(root, name); + const stat = fs.lstatSync(filePath); + if (!entry || !stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Invalid release artifact: ${name}`); + } + const contents = fs.readFileSync(filePath); + const digest = crypto.createHash('sha256').update(contents).digest('hex'); + if (entry.sha256 !== digest || entry.size !== contents.byteLength) { + throw new Error(`Release artifact digest mismatch: ${name}`); + } + fileEntries.push({ name, contents }); + } + const metadataNames = metadata.files.map((entry) => entry.name).sort(); + if (JSON.stringify(metadataNames) !== JSON.stringify(expectedFiles)) { + throw new Error('Release artifact metadata contains unexpected paths.'); + } + + const { owner, repo } = context.repo; + const master = await github.rest.git.getRef({ owner, repo, ref: 'heads/master' }); + if (master.data.object.sha !== baseSha) { + throw new Error(`Master moved from ${baseSha} to ${master.data.object.sha}; rerun release planning.`); + } + const baseCommit = await github.rest.git.getCommit({ owner, repo, commit_sha: baseSha }); + const treeItems = []; + for (const file of fileEntries) { + const blob = await github.rest.git.createBlob({ + owner, + repo, + content: file.contents.toString('base64'), + encoding: 'base64', + }); + treeItems.push({ path: file.name, mode: '100644', type: 'blob', sha: blob.data.sha }); + } + const tree = await github.rest.git.createTree({ + owner, + repo, + base_tree: baseCommit.data.tree.sha, + tree: treeItems, + }); + const title = `release(version): Release ${version}`; + const commitMarker = + `podnotes-release-commit schema=1 version=${version} base=${baseSha}`; + const commit = await github.rest.git.createCommit({ + owner, + repo, + message: `${title}\n\n${commitMarker}`, + tree: tree.data.sha, + parents: [baseSha], + author: { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', + }, + committer: { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', + }, + }); + const branch = `release/${version}`; + const ref = `heads/${branch}`; + let existingRef; + try { + existingRef = await github.rest.git.getRef({ owner, repo, ref }); + } catch (error) { + if (error.status !== 404) throw error; + } + + const openPulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + head: `${owner}:${branch}`, + per_page: 100, + }); + if (openPulls.length > 1) throw new Error(`Multiple open PRs exist for ${branch}.`); + let pullToUpdate = openPulls[0]; + if (existingRef) { + const existingCommit = await github.rest.repos.getCommit({ + owner, + repo, + ref: existingRef.data.object.sha, + }); + const existingFiles = (existingCommit.data.files || []).map((file) => file.filename).sort(); + if (existingCommit.data.parents.length !== 1 || + existingCommit.data.commit.message.trim() !== + `${title}\n\npodnotes-release-commit schema=1 version=${version} ` + + `base=${existingCommit.data.parents[0].sha}` || + existingCommit.data.commit.author.email !== + '41898282+github-actions[bot]@users.noreply.github.com' || + !(existingCommit.data.files || []).every((file) => file.status === 'modified') || + JSON.stringify(existingFiles) !== JSON.stringify(expectedFiles)) { + throw new Error(`Refusing to overwrite non-generated branch ${branch}.`); + } + if (!pullToUpdate) { + const closedPulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'closed', + head: `${owner}:${branch}`, + per_page: 100, + }); + const matching = closedPulls.filter( + (pull) => pull.head.sha === existingRef.data.object.sha, + ); + if (matching.length > 1) { + throw new Error(`Release branch ${branch} has multiple matching closed PRs.`); + } + if (matching[0]) { + const closed = await github.rest.pulls.get({ + owner, + repo, + pull_number: matching[0].number, + }); + if (closed.data.merged) { + throw new Error(`Refusing to reuse merged release branch ${branch}.`); + } + pullToUpdate = closed.data; + } + } + if (pullToUpdate) { + if (pullToUpdate.user?.login !== 'github-actions[bot]' || + pullToUpdate.base.ref !== 'master' || + pullToUpdate.head.repo?.full_name !== `${owner}/${repo}` || + pullToUpdate.head.ref !== branch || + pullToUpdate.head.sha !== existingRef.data.object.sha) { + throw new Error(`Refusing to overwrite release branch ${branch} without exact PR provenance.`); + } + + if (pullToUpdate.state === 'closed') { + await github.rest.git.deleteRef({ owner, repo, ref }); + existingRef = undefined; + pullToUpdate = undefined; + } else if (!pullToUpdate.draft) { + await github.graphql( + `mutation ConvertReleasePrToDraft($id: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $id }) { + pullRequest { isDraft } + } + }`, + { id: pullToUpdate.node_id }, + ); + } + } + if (existingRef) { + await github.rest.git.updateRef({ + owner, + repo, + ref, + sha: commit.data.sha, + force: true, + }); + } + } + if (!existingRef) { + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/${ref}`, + sha: commit.data.sha, + }); + } + + const body = [ + `Prepare PodNotes ${version} from tested master commit \`${baseSha}\`.`, + '', + 'This machine-generated PR changes only the four synchronized version files.', + 'Merge it manually after the explicitly dispatched Test check passes.', + '', + ``, + ].join('\n'); + let pull; + if (pullToUpdate) { + pull = await github.rest.pulls.update({ + owner, + repo, + pull_number: pullToUpdate.number, + base: 'master', + title, + body, + }); + } else { + pull = await github.rest.pulls.create({ + owner, + repo, + base: 'master', + head: branch, + title, + body, + draft: true, + }); + } + await github.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: 'test.yml', + ref: branch, + inputs: { targetSha: commit.data.sha }, + }); + core.notice(`Release PR #${pull.data.number}: ${pull.data.html_url}`); diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml new file mode 100644 index 0000000..e2d8a7e --- /dev/null +++ b/.github/workflows/release-trigger.yml @@ -0,0 +1,328 @@ +name: Trigger release + +on: + pull_request_target: + types: [closed] + +permissions: {} + +concurrency: + group: release-trigger + cancel-in-progress: false + +jobs: + dispatch: + name: Register and dispatch release + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'master' && + startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + pull-requests: read + steps: + - name: Validate, tag, and dispatch exact release + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + EVENT_REF: ${{ github.ref }} + EVENT_SHA: ${{ github.sha }} + with: + script: | + const { isDeepStrictEqual } = require('node:util'); + + const expectedFiles = ['manifest.json', 'package-lock.json', 'package.json', 'versions.json']; + const { owner, repo } = context.repo; + const repository = `${owner}/${repo}`; + const pullNumber = context.payload.pull_request?.number; + if (!Number.isSafeInteger(pullNumber) || pullNumber < 1) { + throw new Error('Release event has an invalid PR number.'); + } + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (!pull.merged || pull.state !== 'closed' || !pull.merged_at || !pull.merge_commit_sha) { + throw new Error(`PR #${pullNumber} is not merged.`); + } + if (pull.user?.login !== 'github-actions[bot]') { + throw new Error(`PR #${pullNumber} was not created by github-actions[bot].`); + } + if (pull.merged_by?.login !== owner) { + throw new Error(`PR #${pullNumber} was not merged by repository owner ${owner}.`); + } + if (pull.base.ref !== 'master' || pull.head.repo?.full_name !== repository) { + throw new Error(`PR #${pullNumber} has invalid base or repository provenance.`); + } + const branchMatch = /^release\/((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/.exec(pull.head.ref); + if (!branchMatch) throw new Error(`PR #${pullNumber} does not use a valid release branch.`); + const version = branchMatch[1]; + const title = `release(version): Release ${version}`; + if (pull.title !== title) throw new Error(`PR #${pullNumber} title does not match ${version}.`); + + const markerPattern = new RegExp( + `', + ); + const marker = markerPattern.exec(pull.body || ''); + if (!marker) throw new Error(`PR #${pullNumber} is missing valid release provenance.`); + const baseSha = marker[1]; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + const changedFiles = files.map((file) => file.filename).sort(); + if (JSON.stringify(changedFiles) !== JSON.stringify(expectedFiles)) { + throw new Error(`PR #${pullNumber} must change exactly the synchronized version files.`); + } + + const headCommit = await github.rest.git.getCommit({ owner, repo, commit_sha: pull.head.sha }); + if (headCommit.data.parents.length !== 1 || headCommit.data.parents[0].sha !== baseSha) { + throw new Error(`PR #${pullNumber} head does not descend directly from its recorded base.`); + } + if (headCommit.data.message.trim() !== + `${title}\n\npodnotes-release-commit schema=1 version=${version} base=${baseSha}`) { + throw new Error(`PR #${pullNumber} head commit message is invalid.`); + } + + const releaseSha = pull.merge_commit_sha; + const releaseCommit = await github.rest.git.getCommit({ owner, repo, commit_sha: releaseSha }); + if (releaseCommit.data.parents.length !== 1 || releaseCommit.data.parents[0].sha !== baseSha) { + throw new Error(`PR #${pullNumber} was not squash-merged directly onto its recorded base.`); + } + if (releaseCommit.data.message.split('\n')[0] !== `${title} (#${pullNumber})`) { + throw new Error(`PR #${pullNumber} merge commit message is invalid.`); + } + if (releaseCommit.data.tree.sha !== headCommit.data.tree.sha) { + throw new Error(`PR #${pullNumber} merge tree does not match its validated head tree.`); + } + if (process.env.EVENT_REF !== 'refs/heads/master' || process.env.EVENT_SHA !== releaseSha) { + throw new Error( + `Release trigger must run from master at ${releaseSha}; got ` + + `${process.env.EVENT_REF} at ${process.env.EVENT_SHA}.`, + ); + } + + function assertRecord(value, label) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object.`); + } + } + + function versionParts(value, label) { + if (typeof value !== 'string' || + !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) { + throw new Error(`${label} is not a stable semantic version.`); + } + return value.split('.').map(Number); + } + + function compareVersions(left, right) { + const leftParts = versionParts(left, 'candidate version'); + const rightParts = versionParts(right, 'base version'); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] - rightParts[index]; + } + } + return 0; + } + + function rootLockPackage(lock, label) { + assertRecord(lock.packages, `${label}.packages`); + const rootPackage = lock.packages['']; + assertRecord(rootPackage, `${label}.packages[""]`); + return rootPackage; + } + + function synchronizedVersion(packageJson, lock, manifest, label) { + const values = [ + packageJson.version, + lock.version, + rootLockPackage(lock, label).version, + manifest.version, + ]; + if (!values.every((value) => value === values[0])) { + throw new Error(`${label} version fields are not synchronized.`); + } + versionParts(values[0], `${label} version`); + return values[0]; + } + + function equalExceptField(candidate, baseline, field) { + const normalized = structuredClone(candidate); + normalized[field] = baseline[field]; + return isDeepStrictEqual(normalized, baseline); + } + + async function readJsonAt(ref, file) { + const response = await github.rest.repos.getContent({ owner, repo, path: file, ref }); + const data = response.data; + if (Array.isArray(data) || data.type !== 'file' || data.encoding !== 'base64' || + typeof data.content !== 'string') { + throw new Error(`Unable to read regular JSON file ${file} at ${ref}.`); + } + const value = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')); + assertRecord(value, `${file} at ${ref}`); + return value; + } + + const [ + basePackage, + baseLock, + baseManifest, + baseVersions, + nextPackage, + nextLock, + nextManifest, + nextVersions, + ] = await Promise.all([ + readJsonAt(baseSha, 'package.json'), + readJsonAt(baseSha, 'package-lock.json'), + readJsonAt(baseSha, 'manifest.json'), + readJsonAt(baseSha, 'versions.json'), + readJsonAt(releaseSha, 'package.json'), + readJsonAt(releaseSha, 'package-lock.json'), + readJsonAt(releaseSha, 'manifest.json'), + readJsonAt(releaseSha, 'versions.json'), + ]); + const baseVersion = synchronizedVersion( + basePackage, + baseLock, + baseManifest, + 'base', + ); + const nextVersion = synchronizedVersion( + nextPackage, + nextLock, + nextManifest, + 'release', + ); + if (basePackage.name !== 'podnotes' || baseManifest.id !== 'podnotes' || + nextVersion !== version || compareVersions(nextVersion, baseVersion) <= 0) { + throw new Error('Release identity or version progression is invalid.'); + } + if (typeof baseManifest.minAppVersion !== 'string' || !baseManifest.minAppVersion || + typeof nextManifest.minAppVersion !== 'string' || !nextManifest.minAppVersion) { + throw new Error('Manifest compatibility versions must be non-empty strings.'); + } + if (baseVersions[baseVersion] !== baseManifest.minAppVersion || + nextVersions[nextVersion] !== nextManifest.minAppVersion) { + throw new Error('Release version history does not match manifest compatibility.'); + } + if (!equalExceptField(nextPackage, basePackage, 'version')) { + throw new Error('package.json changed outside its version field.'); + } + const normalizedLock = structuredClone(nextLock); + normalizedLock.version = baseLock.version; + rootLockPackage(normalizedLock, 'release lock').version = + rootLockPackage(baseLock, 'base lock').version; + if (!isDeepStrictEqual(normalizedLock, baseLock)) { + throw new Error('package-lock.json changed outside its version fields.'); + } + if (!equalExceptField(nextManifest, baseManifest, 'version')) { + throw new Error('manifest.json changed outside its version field.'); + } + if (Object.prototype.hasOwnProperty.call(baseVersions, nextVersion)) { + throw new Error(`Base versions.json already contains ${nextVersion}.`); + } + const normalizedVersions = structuredClone(nextVersions); + delete normalizedVersions[nextVersion]; + if (!isDeepStrictEqual(normalizedVersions, baseVersions)) { + throw new Error('versions.json changed outside the new release entry.'); + } + + const master = await github.rest.git.getRef({ owner, repo, ref: 'heads/master' }); + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${releaseSha}...${master.data.object.sha}`, + }); + if (!['ahead', 'identical'].includes(comparison.data.status) || + comparison.data.merge_base_commit.sha !== releaseSha) { + throw new Error(`Release commit ${releaseSha} is not on current master.`); + } + + async function resolveTagCommit(tag) { + let reference; + try { + reference = await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` }); + } catch (error) { + if (error.status === 404) return null; + throw error; + } + let object = reference.data.object; + for (let depth = 0; object.type === 'tag' && depth < 5; depth += 1) { + const annotated = await github.rest.git.getTag({ owner, repo, tag_sha: object.sha }); + object = annotated.data.object; + } + if (object.type !== 'commit') { + throw new Error(`Tag ${tag} does not resolve to a commit.`); + } + return object.sha; + } + + const previousTagSha = await resolveTagCommit(baseVersion); + if (!previousTagSha) { + throw new Error(`Previous release tag ${baseVersion} does not exist.`); + } + const [tagPackage, tagLock, tagManifest, tagVersions] = await Promise.all([ + readJsonAt(previousTagSha, 'package.json'), + readJsonAt(previousTagSha, 'package-lock.json'), + readJsonAt(previousTagSha, 'manifest.json'), + readJsonAt(previousTagSha, 'versions.json'), + ]); + if (tagPackage.name !== 'podnotes' || tagManifest.id !== 'podnotes' || + synchronizedVersion(tagPackage, tagLock, tagManifest, 'previous tag') !== + baseVersion || + tagVersions[baseVersion] !== tagManifest.minAppVersion) { + throw new Error(`Previous release tag ${baseVersion} has invalid version metadata.`); + } + const previousComparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${previousTagSha}...${baseSha}`, + }); + if (!['ahead', 'identical'].includes(previousComparison.data.status) || + previousComparison.data.merge_base_commit.sha !== previousTagSha) { + throw new Error(`Previous release tag ${baseVersion} is not an ancestor of the base.`); + } + + const existingTagSha = await resolveTagCommit(version); + if (existingTagSha && existingTagSha !== releaseSha) { + throw new Error(`Tag ${version} points to ${existingTagSha}, expected ${releaseSha}.`); + } + + const runBranch = `release-run/${version}`; + let runRef; + try { + runRef = await github.rest.git.getRef({ owner, repo, ref: `heads/${runBranch}` }); + } catch (error) { + if (error.status !== 404) throw error; + } + if (runRef && runRef.data.object.sha !== releaseSha) { + throw new Error( + `Recovery branch ${runBranch} points to ${runRef.data.object.sha}, expected ${releaseSha}.`, + ); + } + if (!runRef) { + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${runBranch}`, + sha: releaseSha, + }); + } + + await github.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: 'release.yml', + ref: runBranch, + inputs: { releasePr: String(pullNumber) }, + }); + core.notice( + `Dispatched PodNotes ${version} from release PR #${pullNumber} at ${runBranch}.`, + ); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6b4549..3a7cbd9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,109 +3,755 @@ name: Release on: workflow_dispatch: inputs: - dryRun: - description: "Dry run (no actual release)" - required: false - default: false - type: boolean - repository_dispatch: - types: [release-trigger] - -# Default-deny baseline; the release job grants only the scopes it needs below. + releasePr: + description: "Merged machine-generated release PR number to recover" + required: true + type: string + permissions: {} +concurrency: + group: release + cancel-in-progress: false + jobs: - release: - name: Release + resolve: + name: Validate release PR runs-on: ubuntu-latest permissions: - contents: write # create releases/tags and push the version-bump commit - issues: write # semantic-release success/failure comments - pull-requests: write # semantic-release PR comments - id-token: write # mint the OIDC token used to sign artifact attestations - attestations: write # publish build provenance attestations for the assets + contents: read + pull-requests: read outputs: - released: ${{ steps.release.outputs.released }} - version: ${{ steps.extract-version.outputs.version }} + artifact-name: ${{ steps.resolve.outputs.artifact-name }} + base-sha: ${{ steps.resolve.outputs.base-sha }} + release-sha: ${{ steps.resolve.outputs.release-sha }} + pr-number: ${{ steps.resolve.outputs.pr-number }} + source-ref: ${{ steps.resolve.outputs.source-ref }} + version: ${{ steps.resolve.outputs.version }} steps: - - name: Checkout + - name: Validate merged release PR provenance + id: resolve + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DISPATCH_PR: ${{ inputs.releasePr }} + EVENT_REF: ${{ github.ref }} + EVENT_SHA: ${{ github.sha }} + with: + script: | + const expectedFiles = ['manifest.json', 'package-lock.json', 'package.json', 'versions.json']; + const { owner, repo } = context.repo; + const repository = `${owner}/${repo}`; + const rawNumber = process.env.DISPATCH_PR; + if (!/^[1-9]\d*$/.test(rawNumber)) throw new Error('releasePr must be a positive PR number.'); + + const pullNumber = Number(rawNumber); + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (!pull.merged || pull.state !== 'closed' || !pull.merged_at || !pull.merge_commit_sha) { + throw new Error(`PR #${pullNumber} is not merged.`); + } + if (pull.user?.login !== 'github-actions[bot]') { + throw new Error(`PR #${pullNumber} was not created by github-actions[bot].`); + } + if (pull.merged_by?.login !== owner) { + throw new Error(`PR #${pullNumber} was not merged by repository owner ${owner}.`); + } + if (pull.base.ref !== 'master' || pull.head.repo?.full_name !== repository) { + throw new Error(`PR #${pullNumber} has invalid base or repository provenance.`); + } + const branchMatch = /^release\/((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/.exec(pull.head.ref); + if (!branchMatch) throw new Error(`PR #${pullNumber} does not use a valid release branch.`); + const version = branchMatch[1]; + const title = `release(version): Release ${version}`; + if (pull.title !== title) throw new Error(`PR #${pullNumber} title does not match ${version}.`); + + const markerPattern = new RegExp( + `', + ); + const marker = markerPattern.exec(pull.body || ''); + if (!marker) throw new Error(`PR #${pullNumber} is missing valid release provenance.`); + const baseSha = marker[1]; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + const changedFiles = files.map((file) => file.filename).sort(); + if (JSON.stringify(changedFiles) !== JSON.stringify(expectedFiles)) { + throw new Error(`PR #${pullNumber} must change exactly the synchronized version files.`); + } + + const headCommit = await github.rest.git.getCommit({ owner, repo, commit_sha: pull.head.sha }); + if (headCommit.data.parents.length !== 1 || headCommit.data.parents[0].sha !== baseSha) { + throw new Error(`PR #${pullNumber} head does not descend directly from its recorded base.`); + } + if (headCommit.data.message.trim() !== + `${title}\n\npodnotes-release-commit schema=1 version=${version} base=${baseSha}`) { + throw new Error(`PR #${pullNumber} head commit message is invalid.`); + } + + const releaseSha = pull.merge_commit_sha; + const releaseCommit = await github.rest.git.getCommit({ owner, repo, commit_sha: releaseSha }); + if (releaseCommit.data.parents.length !== 1 || releaseCommit.data.parents[0].sha !== baseSha) { + throw new Error(`PR #${pullNumber} was not squash-merged directly onto its recorded base.`); + } + if (releaseCommit.data.message.split('\n')[0] !== `${title} (#${pullNumber})`) { + throw new Error(`PR #${pullNumber} merge commit message is invalid.`); + } + if (releaseCommit.data.tree.sha !== headCommit.data.tree.sha) { + throw new Error(`PR #${pullNumber} merge tree does not match its validated head tree.`); + } + + const allowedSourceRefs = [ + `refs/heads/release-run/${version}`, + `refs/tags/${version}`, + ]; + const sourceRef = process.env.EVENT_REF; + if (!allowedSourceRefs.includes(sourceRef) || process.env.EVENT_SHA !== releaseSha) { + throw new Error( + `Release workflow must run from an exact release ref at ${releaseSha}; got ` + + `${process.env.EVENT_REF} at ${process.env.EVENT_SHA}.`, + ); + } + + const master = await github.rest.git.getRef({ owner, repo, ref: 'heads/master' }); + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${releaseSha}...${master.data.object.sha}`, + }); + if (!['ahead', 'identical'].includes(comparison.data.status) || + comparison.data.merge_base_commit.sha !== releaseSha) { + throw new Error(`Release commit ${releaseSha} is not on current master.`); + } + + core.setOutput('artifact-name', `release-payload-${releaseSha}`); + core.setOutput('base-sha', baseSha); + core.setOutput('release-sha', releaseSha); + core.setOutput('pr-number', String(pullNumber)); + core.setOutput('source-ref', sourceRef); + core.setOutput('version', version); + core.notice(`Validated release PR #${pullNumber} for ${version} at ${releaseSha}.`); + + build: + name: Build and verify + needs: resolve + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout exact release commit uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false + ref: ${{ needs.resolve.outputs.release-sha }} + + - name: Validate release commit and version-file diff + env: + BASE_SHA: ${{ needs.resolve.outputs.base-sha }} + RELEASE_SHA: ${{ needs.resolve.outputs.release-sha }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-parse HEAD^)" = "$BASE_SHA" + git fetch --no-tags origin master + git merge-base --is-ancestor "$RELEASE_SHA" origin/master + ACTUAL_DIFF="$(git diff --name-status --no-renames "$BASE_SHA" "$RELEASE_SHA")" + EXPECTED_DIFF=$'M\tmanifest.json\nM\tpackage-lock.json\nM\tpackage.json\nM\tversions.json' + test "$ACTUAL_DIFF" = "$EXPECTED_DIFF" + + BASE_ROOT="$RUNNER_TEMP/release-base" + mkdir "$BASE_ROOT" + for FILE in package.json package-lock.json manifest.json versions.json; do + git show "$BASE_SHA:$FILE" > "$BASE_ROOT/$FILE" + done + node scripts/release-contract.mjs validate-files \ + --base-root "$BASE_ROOT" \ + --candidate-root . \ + --version "$VERSION" - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: ".nvmrc" - cache: npm - - - name: Validate trigger - id: validate - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" || "${{ github.event_name }}" == "repository_dispatch" ]]; then - echo "valid=true" >> "$GITHUB_OUTPUT" - else - echo "valid=false" >> "$GITHUB_OUTPUT" - fi + package-manager-cache: false - name: Install dependencies - if: steps.validate.outputs.valid == 'true' run: npm ci - - name: Build - if: steps.validate.outputs.valid == 'true' - run: npm run build --if-present + - name: Recompute release plan and notes + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + node scripts/release-plan.mjs \ + --cwd . \ + --expected-version "$VERSION" \ + --notes-output "$RUNNER_TEMP/release-notes.md" \ + --output "$RUNNER_TEMP/release-plan.json" + test -s "$RUNNER_TEMP/release-notes.md" + + - name: Lint + run: npm run lint - - name: Run tests - if: steps.validate.outputs.valid == 'true' + - name: Check formatting + run: npm run format:check + + - name: Type-check + run: npm run typecheck + + - name: Build release assets + run: npm run build + + - name: Test run: npm run test - - name: Release - id: release - if: steps.validate.outputs.valid == 'true' + - name: Setup Python for documentation + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: 3.x + + - name: Install documentation dependencies + run: python -m pip install -r docs/requirements.txt + + - name: Build documentation + run: npm run docs:build + + - name: Revalidate clean release tree env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DRY_RUN: ${{ github.event.inputs.dryRun || 'false' }} + VERSION: ${{ needs.resolve.outputs.version }} run: | - node - <<'NODE' + git diff --exit-code + git diff --cached --exit-code + node scripts/release-contract.mjs validate-files \ + --base-root "$RUNNER_TEMP/release-base" \ + --candidate-root . \ + --version "$VERSION" + + - name: Assemble verified release payload + env: + BASE_SHA: ${{ needs.resolve.outputs.base-sha }} + PR_NUMBER: ${{ needs.resolve.outputs.pr-number }} + RELEASE_SHA: ${{ needs.resolve.outputs.release-sha }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + PAYLOAD="$RUNNER_TEMP/release-payload" + mkdir "$PAYLOAD" + cp main.js manifest.json "$PAYLOAD/" + cp "$RUNNER_TEMP/release-notes.md" "$PAYLOAD/release-notes.md" + node scripts/release-contract.mjs manifest \ + --root "$PAYLOAD" \ + --artifacts main.js,manifest.json \ + --output "$PAYLOAD/release-assets.json" + node --input-type=module - <<'NODE' import fs from 'node:fs'; + import path from 'node:path'; - (async () => { - try { - const { default: semanticRelease } = await import('semantic-release'); - const result = await semanticRelease({ - dryRun: process.env.DRY_RUN === 'true', + const context = { + baseSha: process.env.BASE_SHA, + prNumber: Number(process.env.PR_NUMBER), + releaseSha: process.env.RELEASE_SHA, + repository: process.env.GITHUB_REPOSITORY, + schemaVersion: 1, + version: process.env.VERSION, + }; + fs.writeFileSync( + path.join(process.env.RUNNER_TEMP, 'release-payload', 'release-context.json'), + `${JSON.stringify(context, null, 2)}\n`, + { flag: 'wx' }, + ); + NODE + + - name: Upload verified build payload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ needs.resolve.outputs.artifact-name }} + path: ${{ runner.temp }}/release-payload + if-no-files-found: error + retention-days: 1 + + tag: + name: Register verified release tag + needs: [resolve, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create or verify exact release tag + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + RELEASE_SHA: ${{ needs.resolve.outputs.release-sha }} + VERSION: ${{ needs.resolve.outputs.version }} + with: + script: | + const releaseSha = process.env.RELEASE_SHA; + const version = process.env.VERSION; + if (!/^[0-9a-f]{40}$/.test(releaseSha) || + !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + throw new Error('Invalid release tag identity.'); + } + const { owner, repo } = context.repo; + + function compareVersions(left, right) { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] - rightParts[index]; + } + } + return 0; + } + + async function assertNoHigherStableTag() { + const refs = await github.paginate(github.rest.git.listMatchingRefs, { + owner, + repo, + ref: 'tags/', + per_page: 100, }); + const stableTags = refs + .map((reference) => reference.ref.replace('refs/tags/', '')) + .filter((tag) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag)); + const higher = stableTags.find((tag) => compareVersions(tag, version) > 0); + if (higher) { + throw new Error(`Higher stable tag ${higher} appeared before release ${version}.`); + } + } - if (!result || process.env.DRY_RUN === 'true') { - fs.appendFileSync(process.env.GITHUB_OUTPUT, 'released=false\n'); - process.exit(0); + async function resolveTagCommit() { + let reference; + try { + reference = await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }); + } catch (error) { + if (error.status === 404) return null; + throw error; + } + let object = reference.data.object; + for (let depth = 0; object.type === 'tag' && depth < 5; depth += 1) { + const annotated = await github.rest.git.getTag({ owner, repo, tag_sha: object.sha }); + object = annotated.data.object; + } + if (object.type !== 'commit') { + throw new Error(`Tag ${version} does not resolve to a commit.`); } + return object.sha; + } - fs.appendFileSync(process.env.GITHUB_OUTPUT, 'released=true\n'); - fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.nextRelease.version}\n`); - } catch (error) { - console.error(error); - process.exit(1); + await assertNoHigherStableTag(); + let tagSha = await resolveTagCommit(); + if (!tagSha) { + try { + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${version}`, + sha: releaseSha, + }); + } catch (error) { + if (error.status !== 422) throw error; + } + tagSha = await resolveTagCommit(); + } + if (tagSha !== releaseSha) { + throw new Error(`Tag ${version} points to ${tagSha}, expected ${releaseSha}.`); + } + await assertNoHigherStableTag(); + core.notice(`Release tag ${version} targets verified commit ${releaseSha}.`); + + attest: + name: Attest verified assets + needs: [resolve, build, tag] + runs-on: ubuntu-latest + permissions: + actions: read + attestations: write + contents: read + id-token: write + steps: + - name: Download verified build payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.resolve.outputs.artifact-name }} + path: release-payload + + - name: Validate attestation inputs + env: + EXPECTED_BASE: ${{ needs.resolve.outputs.base-sha }} + EXPECTED_PR: ${{ needs.resolve.outputs.pr-number }} + EXPECTED_SHA: ${{ needs.resolve.outputs.release-sha }} + EXPECTED_VERSION: ${{ needs.resolve.outputs.version }} + run: | + node - <<'NODE' + const crypto = require('node:crypto'); + const fs = require('node:fs'); + const path = require('node:path'); + + const root = path.resolve('release-payload'); + const expectedEntries = [ + 'main.js', + 'manifest.json', + 'release-assets.json', + 'release-context.json', + 'release-notes.md', + ]; + if (JSON.stringify(fs.readdirSync(root).sort()) !== JSON.stringify(expectedEntries)) { + throw new Error('Attestation payload contains unexpected files.'); + } + for (const name of expectedEntries) { + const stat = fs.lstatSync(path.join(root, name)); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Attestation payload entry is not a regular file: ${name}`); } - })(); + } + + const context = JSON.parse(fs.readFileSync(path.join(root, 'release-context.json'), 'utf8')); + if (context.schemaVersion !== 1 || + context.baseSha !== process.env.EXPECTED_BASE || + context.prNumber !== Number(process.env.EXPECTED_PR) || + context.releaseSha !== process.env.EXPECTED_SHA || + context.repository !== process.env.GITHUB_REPOSITORY || + context.version !== process.env.EXPECTED_VERSION) { + throw new Error('Attestation context does not match validated workflow outputs.'); + } + + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'release-assets.json'), 'utf8')); + const names = ['main.js', 'manifest.json']; + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.artifacts) || + JSON.stringify(manifest.artifacts.map((entry) => entry.name).sort()) !== JSON.stringify(names)) { + throw new Error('Attestation asset manifest is invalid.'); + } + for (const name of names) { + const contents = fs.readFileSync(path.join(root, name)); + const entry = manifest.artifacts.find((candidate) => candidate.name === name); + const digest = crypto.createHash('sha256').update(contents).digest('hex'); + if (!entry || entry.size !== contents.byteLength || entry.sha256 !== digest) { + throw new Error(`Attestation asset does not match its manifest: ${name}`); + } + } + const pluginManifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8')); + if (pluginManifest.id !== 'podnotes' || pluginManifest.version !== process.env.EXPECTED_VERSION) { + throw new Error('Plugin manifest identity does not match the expected release.'); + } + if (!fs.readFileSync(path.join(root, 'release-notes.md'), 'utf8').trim()) { + throw new Error('Release notes are empty.'); + } NODE - # Generate cryptographic build-provenance attestations for the released - # assets so users can verify they were built from this repository's source - # by this workflow (GitHub attestations API). - - name: Attest build provenance for release assets - if: steps.release.outputs.released == 'true' + - name: Attest release assets uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | - main.js - manifest.json + release-payload/main.js + release-payload/manifest.json - - name: Extract version - id: extract-version - if: steps.release.outputs.released == 'true' + publish: + name: Publish verified release + needs: [resolve, build, tag, attest] + runs-on: ubuntu-latest + permissions: + actions: read + attestations: read + contents: write + steps: + - name: Download verified release payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.resolve.outputs.artifact-name }} + path: release-payload + + - name: Verify GitHub attestations + env: + EXPECTED_SHA: ${{ needs.resolve.outputs.release-sha }} + GH_TOKEN: ${{ github.token }} + SOURCE_REF: ${{ needs.resolve.outputs.source-ref }} run: | - VERSION=$(node -p "require('./package.json').version") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + SIGNER_WORKFLOW="github.com/$GITHUB_REPOSITORY/.github/workflows/release.yml" + for ASSET in main.js manifest.json; do + gh attestation verify "release-payload/$ASSET" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$SIGNER_WORKFLOW" \ + --source-ref "$SOURCE_REF" \ + --source-digest "$EXPECTED_SHA" \ + --deny-self-hosted-runners + done + + - name: Upload, verify, and publish release + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + EXPECTED_BASE: ${{ needs.resolve.outputs.base-sha }} + EXPECTED_PR: ${{ needs.resolve.outputs.pr-number }} + EXPECTED_SHA: ${{ needs.resolve.outputs.release-sha }} + EXPECTED_VERSION: ${{ needs.resolve.outputs.version }} + with: + script: | + const crypto = require('node:crypto'); + const fs = require('node:fs'); + const path = require('node:path'); + + const root = path.resolve('release-payload'); + const expectedEntries = [ + 'main.js', + 'manifest.json', + 'release-assets.json', + 'release-context.json', + 'release-notes.md', + ]; + const actualEntries = fs.readdirSync(root).sort(); + if (JSON.stringify(actualEntries) !== JSON.stringify(expectedEntries)) { + throw new Error('Release payload contains unexpected files.'); + } + for (const name of expectedEntries) { + const stat = fs.lstatSync(path.join(root, name)); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Release payload entry is not a regular file: ${name}`); + } + } + + const releaseContext = JSON.parse(fs.readFileSync(path.join(root, 'release-context.json'), 'utf8')); + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'release-assets.json'), 'utf8')); + const version = process.env.EXPECTED_VERSION; + const releaseSha = process.env.EXPECTED_SHA; + const expectedPr = Number(process.env.EXPECTED_PR); + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version) || + !/^[0-9a-f]{40}$/.test(releaseSha) || + !Number.isSafeInteger(expectedPr) || expectedPr < 1) { + throw new Error('Invalid expected release identity.'); + } + if (releaseContext.schemaVersion !== 1 || + releaseContext.baseSha !== process.env.EXPECTED_BASE || + releaseContext.version !== version || + releaseContext.releaseSha !== releaseSha || + releaseContext.prNumber !== expectedPr || + releaseContext.repository !== process.env.GITHUB_REPOSITORY) { + throw new Error('Release context does not match validated workflow outputs.'); + } + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.artifacts) || + manifest.artifacts.length !== 2) { + throw new Error('Invalid release asset manifest.'); + } + + const assetNames = ['main.js', 'manifest.json']; + const assets = new Map(); + for (const name of assetNames) { + const entry = manifest.artifacts.find((candidate) => candidate.name === name); + const contents = fs.readFileSync(path.join(root, name)); + const digest = crypto.createHash('sha256').update(contents).digest('hex'); + if (!entry || entry.sha256 !== digest || entry.size !== contents.byteLength) { + throw new Error(`Local release asset does not match its manifest: ${name}`); + } + assets.set(name, { contents, digest, size: contents.byteLength }); + } + const manifestNames = manifest.artifacts.map((entry) => entry.name).sort(); + if (JSON.stringify(manifestNames) !== JSON.stringify(assetNames)) { + throw new Error('Release asset manifest contains unexpected names.'); + } + + const notes = fs.readFileSync(path.join(root, 'release-notes.md'), 'utf8'); + if (!notes.trim()) throw new Error('Release notes are empty.'); + const pluginManifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8')); + if (pluginManifest.id !== 'podnotes' || pluginManifest.version !== version) { + throw new Error('Plugin manifest identity does not match the expected release.'); + } + const { owner, repo } = context.repo; + + async function resolveTagCommit(tag) { + let reference; + try { + reference = await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` }); + } catch (error) { + if (error.status === 404) return null; + throw error; + } + let object = reference.data.object; + for (let depth = 0; object.type === 'tag' && depth < 5; depth += 1) { + const annotated = await github.rest.git.getTag({ owner, repo, tag_sha: object.sha }); + object = annotated.data.object; + } + if (object.type !== 'commit') throw new Error(`Tag ${tag} does not resolve to a commit.`); + return object.sha; + } + + async function removeRecoveryBranch() { + const ref = `heads/release-run/${version}`; + let branch; + try { + branch = await github.rest.git.getRef({ owner, repo, ref }); + } catch (error) { + if (error.status === 404) return; + throw error; + } + if (branch.data.object.sha !== releaseSha) { + throw new Error( + `Recovery branch points to ${branch.data.object.sha}, expected ${releaseSha}.`, + ); + } + await github.rest.git.deleteRef({ owner, repo, ref }); + } + + async function downloadDigest(asset) { + const response = await github.request( + 'GET /repos/{owner}/{repo}/releases/assets/{asset_id}', + { + owner, + repo, + asset_id: asset.id, + headers: { accept: 'application/octet-stream' }, + }, + ); + let contents; + if (Buffer.isBuffer(response.data)) contents = response.data; + else if (response.data instanceof ArrayBuffer) contents = Buffer.from(response.data); + else if (ArrayBuffer.isView(response.data)) { + contents = Buffer.from(response.data.buffer, response.data.byteOffset, response.data.byteLength); + } else { + contents = Buffer.from(response.data); + } + return crypto.createHash('sha256').update(contents).digest('hex'); + } + + async function verifyRemoteAssets(releaseId) { + const remoteAssets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, + repo, + release_id: releaseId, + per_page: 100, + }); + const names = remoteAssets.map((asset) => asset.name).sort(); + if (JSON.stringify(names) !== JSON.stringify(assetNames)) { + throw new Error('GitHub release contains unexpected or missing assets.'); + } + for (const remote of remoteAssets) { + const expected = assets.get(remote.name); + if (!expected || remote.size !== expected.size) { + throw new Error(`GitHub release asset size mismatch: ${remote.name}`); + } + if (remote.digest && remote.digest !== `sha256:${expected.digest}`) { + throw new Error(`GitHub release asset API digest mismatch: ${remote.name}`); + } + const downloadedDigest = await downloadDigest(remote); + if (downloadedDigest !== expected.digest) { + throw new Error(`Downloaded GitHub release asset digest mismatch: ${remote.name}`); + } + } + } + + const tagSha = await resolveTagCommit(version); + if (tagSha !== releaseSha) { + throw new Error(`Tag ${version} points to ${tagSha}, expected ${releaseSha}.`); + } + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + const matches = releases.filter((release) => release.tag_name === version); + if (matches.length > 1) throw new Error(`Multiple releases use tag ${version}.`); + let release = matches[0]; + if (!release) { + const created = await github.rest.repos.createRelease({ + owner, + repo, + tag_name: version, + target_commitish: releaseSha, + name: version, + body: notes, + draft: true, + prerelease: false, + }); + release = created.data; + } + const resolvedTagSha = await resolveTagCommit(version); + if (resolvedTagSha !== releaseSha) { + throw new Error(`Release tag ${version} does not resolve to ${releaseSha}.`); + } + + if (!release.draft) { + if (release.name !== version || release.body !== notes || release.prerelease) { + throw new Error(`Published release ${version} metadata does not match.`); + } + await verifyRemoteAssets(release.id); + if (await resolveTagCommit(version) !== releaseSha) { + throw new Error(`Published release tag ${version} changed during verification.`); + } + await removeRecoveryBranch(); + core.notice(`Release ${version} was already published and verifies exactly.`); + return; + } + + const updated = await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + name: version, + body: notes, + draft: true, + prerelease: false, + }); + release = updated.data; + const existingAssets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, + repo, + release_id: release.id, + per_page: 100, + }); + for (const asset of existingAssets) { + await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id }); + } + for (const [name, asset] of assets) { + await github.rest.repos.uploadReleaseAsset({ + owner, + repo, + release_id: release.id, + name, + headers: { + 'content-type': name.endsWith('.json') ? 'application/json' : 'text/javascript', + 'content-length': asset.size, + }, + data: asset.contents, + }); + } + await verifyRemoteAssets(release.id); + + if (await resolveTagCommit(version) !== releaseSha) { + throw new Error(`Release tag ${version} changed before publication.`); + } + try { + const published = await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + name: version, + body: notes, + draft: false, + prerelease: false, + make_latest: 'true', + }); + if (published.data.draft || published.data.tag_name !== version) { + throw new Error(`GitHub did not publish release ${version}.`); + } + if (await resolveTagCommit(version) !== releaseSha) { + throw new Error(`Release tag ${version} changed during publication.`); + } + await verifyRemoteAssets(release.id); + } catch (error) { + try { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + name: version, + body: notes, + draft: true, + prerelease: false, + }); + core.warning(`Returned release ${version} to draft after final verification failed.`); + } catch (rollbackError) { + core.error(`Could not return release ${version} to draft: ${rollbackError.message}`); + } + throw error; + } + await removeRecoveryBranch(); + core.notice(`Published and verified PodNotes ${version} at ${releaseSha}.`); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ffd8ff4..3c93292 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,11 @@ name: Test on: + workflow_dispatch: + inputs: + targetSha: + description: "Exact commit SHA to test" + required: true + type: string pull_request: push: branches: @@ -17,6 +23,17 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + ref: ${{ inputs.targetSha || github.sha }} + - name: Verify dispatched commit + if: github.event_name == 'workflow_dispatch' + env: + TARGET_SHA: ${{ inputs.targetSha }} + run: | + if [[ ! "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "targetSha must be a full commit SHA" >&2 + exit 1 + fi + test "$(git rev-parse HEAD)" = "$TARGET_SHA" - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/AGENTS.md b/AGENTS.md index fdd1620..bdd820b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,8 @@ root symlinks for local Obsidian loading. this. - Use npm for package management and scripts. Do not introduce another package manager unless the migration is intentional and removes the old lockfile. -- Use Conventional Commits (`feat:`, `fix:`, `test:`, `docs:`, `chore:`) so - semantic-release can determine versions. +- Use Conventional Commits (`feat:`, `fix:`, `test:`, `docs:`, `chore:`) so the + automated release planner can determine versions and generate release notes. - If work resolves a GitHub issue, prefer an issue-linked branch workflow before implementation. @@ -183,15 +183,48 @@ transcript behavior, local-file behavior, or import/export changes. Use `npm run docs:build` to validate docs locally. The Cloudflare Pages output directory is `docs/site`, configured in `wrangler.jsonc`. -## Release Notes -The release setup is semantic-release based. `npm version` runs -`version-bump.mjs`, which keeps `manifest.json` and `versions.json` in sync with -the package version and Obsidian `minAppVersion`. +## Release Workflow +Goal: publish each version from a tested, reviewable, and cryptographically +attested commit. + +Success means: +- the release PR changes exactly `package.json`, `package-lock.json`, + `manifest.json`, and `versions.json`; +- the release commit passes the full lint, format, type, build, test, and docs + gates; +- `main.js` and `manifest.json` receive build-provenance attestations and match + the assets downloaded back from the draft GitHub release. + +Stop when: the GitHub release is public, its tag targets the validated release +commit, and both remote assets match their recorded SHA-256 digests. + +After a successful `Test` run on `master`, the no-checkout +`Trigger release preparation` workflow dispatches `Prepare release` at the +exact tested commit. `Prepare release` uses `npm run release:plan` to calculate +the next Conventional Commits version and opens a machine-generated draft PR +containing the four synchronized version files. Review that exact diff, wait +for its explicitly dispatched `Test` run, mark the PR ready, and squash-merge +it with the generated title unchanged. + +Keep the repository's default `GITHUB_TOKEN` permission read-only. Enable the +repository setting that lets GitHub Actions create and approve pull requests so +the narrowly scoped `Open release PR` job can create the machine-generated PR; +the publisher separately requires the repository owner to perform the merge. + +The no-checkout `Trigger release` workflow validates the merged PR, creates an +exact `release-run/` recovery branch, and dispatches `Release` from +that ref. `Release` revalidates the PR provenance and field-level diff before +installing dependencies. It recomputes the exact version, runs every build +gate, creates the durable release tag, attests `main.js` and `manifest.json`, +uploads both to a draft GitHub release, downloads and hashes the remote assets, +and publishes the release as the final step. Successful publication removes +the recovery branch. Recover an interrupted run from its exact remaining ref: -Release assets are `main.js` and `manifest.json`. Keep version metadata changes -generated by the release process with the release commit, and treat unexpected -diffs in `package.json`, `package-lock.json`, `manifest.json`, or -`versions.json` as blockers until understood. +```bash +gh workflow run release.yml --ref release-run/ -f releasePr= +# After the durable tag exists: +gh workflow run release.yml --ref -f releasePr= +``` ## PR Expectations Pull requests should include: diff --git a/package-lock.json b/package-lock.json index d9cdf63..d447b40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "openai": "^6.45.0" }, "devDependencies": { - "@semantic-release/git": "^10.0.1", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/release-notes-generator": "^14.1.1", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.4.2", @@ -3276,39 +3277,6 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/error": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", - "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@semantic-release/git": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", - "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "debug": "^4.0.0", - "dir-glob": "^3.0.0", - "execa": "^5.0.0", - "lodash": "^4.17.4", - "micromatch": "^4.0.0", - "p-reduce": "^2.0.0" - }, - "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "semantic-release": ">=18.0.0" - } - }, "node_modules/@semantic-release/github": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.2.tgz", @@ -3677,9 +3645,9 @@ } }, "node_modules/@semantic-release/release-notes-generator": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", - "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", "dev": true, "license": "MIT", "dependencies": { @@ -3688,9 +3656,7 @@ "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", - "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", - "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, @@ -3701,19 +3667,6 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -4769,20 +4722,6 @@ "node": ">= 14" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -5239,16 +5178,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -7399,30 +7328,6 @@ "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -7598,57 +7503,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, - "node_modules/from2/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/from2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/from2/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/from2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8118,16 +7972,6 @@ "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -8245,23 +8089,6 @@ "node": ">= 0.4" } }, - "node_modules/into-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", - "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8614,19 +8441,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -9355,13 +9169,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -9642,16 +9449,6 @@ "node": ">=16" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -10034,19 +9831,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/@gar/promise-retry": { "version": "1.0.3", "dev": true, @@ -11964,22 +11748,6 @@ ], "license": "MIT" }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/openai": { "version": "6.45.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", @@ -12211,16 +11979,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -12266,16 +12024,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-reduce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", - "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-timeout": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", @@ -13659,13 +13407,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/signale": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", @@ -14001,16 +13742,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", diff --git a/package.json b/package.json index 51829bd..d084e2f 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,7 @@ "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.test.json --noEmit", "lint": "oxlint --deny-warnings src tests/e2e scripts", "format:check": "npm run format -- --check", - "version": "node version-bump.mjs && git add manifest.json versions.json", - "semantic-release": "semantic-release", + "release:plan": "node scripts/release-plan.mjs", "test": "npm run check:a11y && vitest", "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts", "obsidian:e2e": "node scripts/obsidian-e2e-cli.mjs", @@ -26,14 +25,15 @@ "check:a11y": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings && svelte-check --tsconfig ./tsconfig.test.json --fail-on-warnings", "docs:build": "mkdocs build -f docs/mkdocs.yml -d site", "docs:deploy": "npm run docs:build && wrangler pages deploy docs/site --project-name podnotes --branch master", - "format": "oxfmt src tests scripts .github package.json manifest.json versions.json tsconfig.base.json tsconfig.json tsconfig.test.json .oxlintrc.json .oxfmtrc.json svelte.config.mjs vite.config.ts vitest.config.ts vitest.e2e.config.ts vitest.setup.ts version-bump.mjs wrangler.jsonc orca.yaml docs/mkdocs.yml" + "format": "oxfmt src tests scripts .github package.json manifest.json versions.json tsconfig.base.json tsconfig.json tsconfig.test.json .oxlintrc.json .oxfmtrc.json svelte.config.mjs vite.config.ts vitest.config.ts vitest.e2e.config.ts vitest.setup.ts wrangler.jsonc orca.yaml docs/mkdocs.yml" }, "dependencies": { "fuse.js": "^7.4.2", "openai": "^6.45.0" }, "devDependencies": { - "@semantic-release/git": "^10.0.1", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/release-notes-generator": "^14.1.1", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.4.2", @@ -58,61 +58,6 @@ "undici": "^6.27.0" } }, - "release": { - "tagFormat": "${version}", - "plugins": [ - [ - "@semantic-release/commit-analyzer", - { - "releaseRules": [ - { - "type": "chore", - "release": "patch" - }, - { - "type": "build", - "scope": "deps", - "release": "patch" - } - ] - } - ], - "@semantic-release/release-notes-generator", - [ - "@semantic-release/npm", - { - "npmPublish": false - } - ], - [ - "@semantic-release/git", - { - "assets": [ - "package.json", - "package-lock.json", - "manifest.json", - "versions.json" - ], - "message": "release(version): Release ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - } - ], - [ - "@semantic-release/github", - { - "assets": [ - { - "path": "main.js", - "label": "main.js" - }, - { - "path": "manifest.json", - "label": "manifest.json" - } - ] - } - ] - ] - }, "engines": { "node": "^22.14.0" } diff --git a/scripts/release-contract.mjs b/scripts/release-contract.mjs new file mode 100644 index 0000000..e70dbae --- /dev/null +++ b/scripts/release-contract.mjs @@ -0,0 +1,404 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +export const RELEASE_FILES = [ + "package.json", + "package-lock.json", + "manifest.json", + "versions.json", +]; +export const RELEASE_ASSETS = ["main.js", "manifest.json"]; + +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +/** + * @param {unknown} value + * @param {string} label + * @returns {asserts value is Record} + */ +function assertRecord(value, label) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object.`); + } +} + +/** @param {unknown} value */ +export function assertReleaseVersion(value) { + if (typeof value !== "string" || !SEMVER_PATTERN.test(value)) { + throw new Error(`Invalid release version: ${String(value)}`); + } + return value; +} + +/** + * @param {string} root + * @param {string} fileName + */ +function safeFilePath(root, fileName) { + if (!RELEASE_FILES.includes(fileName) && !RELEASE_ASSETS.includes(fileName)) { + throw new Error(`Unsupported release file: ${fileName}`); + } + const resolvedRoot = path.resolve(root); + const resolvedFile = path.resolve(resolvedRoot, fileName); + if (path.dirname(resolvedFile) !== resolvedRoot) { + throw new Error(`Release file escapes its root: ${fileName}`); + } + return resolvedFile; +} + +/** @param {string} filePath */ +function readRegularFile(filePath) { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Release input must be a regular file: ${filePath}`); + } + return fs.readFileSync(filePath); +} + +/** + * @param {string} root + * @param {string} fileName + */ +function readJson(root, fileName) { + const filePath = safeFilePath(root, fileName); + const value = /** @type {unknown} */ (JSON.parse(readRegularFile(filePath).toString("utf8"))); + assertRecord(value, fileName); + return value; +} + +/** + * @param {string} filePath + * @param {Record} value + */ +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, "\t")}\n`, { + encoding: "utf8", + flag: "wx", + }); +} + +/** @param {Buffer} contents */ +function fileMetadata(contents) { + return { + sha256: crypto.createHash("sha256").update(contents).digest("hex"), + size: contents.byteLength, + }; +} + +/** + * @param {Record} packageLock + * @param {string} label + */ +function lockRootPackage(packageLock, label) { + assertRecord(packageLock.packages, `${label}.packages`); + const rootPackage = packageLock.packages[""]; + assertRecord(rootPackage, `${label}.packages[""]`); + return rootPackage; +} + +/** + * @param {Record} packageJson + * @param {Record} packageLock + * @param {Record} manifest + * @param {string} label + * @returns {string} + */ +function assertCurrentVersions(packageJson, packageLock, manifest, label) { + const rootPackage = lockRootPackage(packageLock, label); + const versions = [ + packageJson.version, + packageLock.version, + rootPackage.version, + manifest.version, + ]; + if (!versions.every((version) => typeof version === "string" && version === versions[0])) { + throw new Error(`${label} version fields are not synchronized.`); + } + return assertReleaseVersion(versions[0]); +} + +/** @param {string} left @param {string} right */ +function compareVersions(left, right) { + const leftParts = assertReleaseVersion(left).split(".").map(Number); + const rightParts = assertReleaseVersion(right).split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] - rightParts[index]; + } + } + return 0; +} + +/** @param {string} root */ +export function validateCurrentVersionFiles(root) { + const packageJson = readJson(root, "package.json"); + const packageLock = readJson(root, "package-lock.json"); + const manifest = readJson(root, "manifest.json"); + const versions = readJson(root, "versions.json"); + const version = assertCurrentVersions(packageJson, packageLock, manifest, "current"); + if (typeof manifest.minAppVersion !== "string" || !manifest.minAppVersion) { + throw new Error("manifest.json minAppVersion must be a non-empty string."); + } + if (versions[version] !== manifest.minAppVersion) { + throw new Error("versions.json does not record the current manifest version."); + } + return { minAppVersion: manifest.minAppVersion, version }; +} + +/** + * @param {{ root: string; out: string; version: string; baseSha: string }} options + */ +export function materializeVersionFiles(options) { + const version = assertReleaseVersion(options.version); + if (!/^[0-9a-f]{40}$/i.test(options.baseSha)) { + throw new Error(`Invalid base SHA: ${options.baseSha}`); + } + + const outputRoot = path.resolve(options.out); + fs.mkdirSync(outputRoot, { recursive: true }); + const outputStat = fs.lstatSync(outputRoot); + if (!outputStat.isDirectory() || outputStat.isSymbolicLink()) { + throw new Error(`Release output must be a real directory: ${outputRoot}`); + } + if (fs.readdirSync(outputRoot).length > 0) { + throw new Error(`Release output directory must be empty: ${outputRoot}`); + } + + const packageJson = structuredClone(readJson(options.root, "package.json")); + const packageLock = structuredClone(readJson(options.root, "package-lock.json")); + const manifest = structuredClone(readJson(options.root, "manifest.json")); + const versions = structuredClone(readJson(options.root, "versions.json")); + const currentVersion = assertCurrentVersions(packageJson, packageLock, manifest, "source"); + if (compareVersions(version, currentVersion) <= 0) { + throw new Error(`Release version ${version} must be newer than ${currentVersion}.`); + } + if (versions[currentVersion] !== manifest.minAppVersion) { + throw new Error( + "versions.json version history is not synchronized with the current manifest.", + ); + } + if (Object.prototype.hasOwnProperty.call(versions, version)) { + throw new Error(`versions.json already contains ${version}.`); + } + if (typeof manifest.minAppVersion !== "string" || !manifest.minAppVersion) { + throw new Error("manifest.json minAppVersion must be a non-empty string."); + } + + packageJson.version = version; + packageLock.version = version; + lockRootPackage(packageLock, "package-lock.json").version = version; + manifest.version = version; + versions[version] = manifest.minAppVersion; + + const values = new Map([ + ["package.json", packageJson], + ["package-lock.json", packageLock], + ["manifest.json", manifest], + ["versions.json", versions], + ]); + const files = []; + for (const fileName of RELEASE_FILES) { + const outputPath = safeFilePath(outputRoot, fileName); + const value = values.get(fileName); + if (!value) throw new Error(`Missing materialized value for ${fileName}.`); + writeJson(outputPath, value); + const contents = readRegularFile(outputPath); + files.push({ name: fileName, ...fileMetadata(contents) }); + } + + const metadata = { + baseSha: options.baseSha.toLowerCase(), + files, + schemaVersion: 1, + version, + }; + writeJson(path.join(outputRoot, "release-version-files.json"), metadata); + return metadata; +} + +/** + * @param {Record} candidate + * @param {Record} baseline + * @param {string} field + */ +function equalExceptField(candidate, baseline, field) { + const normalized = structuredClone(candidate); + normalized[field] = baseline[field]; + return isDeepStrictEqual(normalized, baseline); +} + +/** + * @param {{ baseRoot: string; candidateRoot: string; version: string }} options + */ +export function validateVersionFiles(options) { + const version = assertReleaseVersion(options.version); + const basePackage = readJson(options.baseRoot, "package.json"); + const nextPackage = readJson(options.candidateRoot, "package.json"); + const baseLock = readJson(options.baseRoot, "package-lock.json"); + const nextLock = readJson(options.candidateRoot, "package-lock.json"); + const baseManifest = readJson(options.baseRoot, "manifest.json"); + const nextManifest = readJson(options.candidateRoot, "manifest.json"); + const baseVersions = readJson(options.baseRoot, "versions.json"); + const nextVersions = readJson(options.candidateRoot, "versions.json"); + + const baseVersion = assertCurrentVersions(basePackage, baseLock, baseManifest, "base"); + assertCurrentVersions(nextPackage, nextLock, nextManifest, "candidate"); + if (compareVersions(version, baseVersion) <= 0) { + throw new Error(`Release version ${version} must be newer than ${baseVersion}.`); + } + if (baseVersions[baseVersion] !== baseManifest.minAppVersion) { + throw new Error("Base versions.json is not synchronized with its manifest."); + } + if (nextPackage.version !== version) { + throw new Error( + `Candidate version is ${String(nextPackage.version)}, expected ${version}.`, + ); + } + if (!equalExceptField(nextPackage, basePackage, "version")) { + throw new Error("package.json changed outside its version field."); + } + + const normalizedLock = structuredClone(nextLock); + normalizedLock.version = baseLock.version; + lockRootPackage(normalizedLock, "candidate package-lock.json").version = lockRootPackage( + baseLock, + "base package-lock.json", + ).version; + if (!isDeepStrictEqual(normalizedLock, baseLock)) { + throw new Error("package-lock.json changed outside its version fields."); + } + if (!equalExceptField(nextManifest, baseManifest, "version")) { + throw new Error("manifest.json changed outside its version field."); + } + if (Object.prototype.hasOwnProperty.call(baseVersions, version)) { + throw new Error(`Base versions.json already contains ${version}.`); + } + if (nextVersions[version] !== nextManifest.minAppVersion) { + throw new Error(`versions.json does not map ${version} to manifest minAppVersion.`); + } + const normalizedVersions = structuredClone(nextVersions); + delete normalizedVersions[version]; + if (!isDeepStrictEqual(normalizedVersions, baseVersions)) { + throw new Error("versions.json changed outside the new release entry."); + } + return { version }; +} + +/** + * @param {unknown} input + */ +export function validateReleasePr(input) { + assertRecord(input, "release PR input"); + const version = assertReleaseVersion(input.version); + const expectedFiles = [...RELEASE_FILES].sort(); + if ( + !Array.isArray(input.changedFiles) || + !input.changedFiles.every((file) => typeof file === "string") || + !isDeepStrictEqual([...input.changedFiles].sort(), expectedFiles) + ) { + throw new Error(`Release PR must change exactly: ${RELEASE_FILES.join(", ")}.`); + } + if (input.baseRef !== "master") throw new Error("Release PR base must be master."); + if (input.branch !== `release/${version}`) + throw new Error("Release PR branch/version mismatch."); + if (input.title !== `release(version): Release ${version}`) { + throw new Error("Release PR title/version mismatch."); + } + if (typeof input.repository !== "string" || input.headRepository !== input.repository) { + throw new Error("Release PR must originate from this repository."); + } + return { version }; +} + +/** + * @param {{ root: string; artifacts: string[]; output?: string }} options + */ +export function createArtifactManifest(options) { + if (!isDeepStrictEqual([...options.artifacts].sort(), [...RELEASE_ASSETS].sort())) { + throw new Error(`Release assets must be exactly: ${RELEASE_ASSETS.join(", ")}.`); + } + const artifacts = options.artifacts.map((name) => { + const contents = readRegularFile(safeFilePath(options.root, name)); + return { name, ...fileMetadata(contents) }; + }); + const manifest = { artifacts, schemaVersion: 1 }; + if (options.output) { + const outputPath = path.resolve(options.output); + const outputDirectory = fs.lstatSync(path.dirname(outputPath)); + if (!outputDirectory.isDirectory() || outputDirectory.isSymbolicLink()) { + throw new Error(`Manifest output parent must be a real directory: ${options.output}`); + } + writeJson(outputPath, manifest); + } + return manifest; +} + +/** @param {string[]} argv */ +function parseOptions(argv) { + const command = argv[0]; + /** @type {Record} */ + const options = {}; + for (let index = 1; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error(`Invalid release-contract argument near ${String(key)}.`); + } + options[key.slice(2)] = value; + } + return { command, options }; +} + +/** @param {Record} options @param {string} name */ +function requiredOption(options, name) { + const value = options[name]; + if (!value) throw new Error(`Missing --${name}.`); + return value; +} + +async function main() { + const { command, options } = parseOptions(process.argv.slice(2)); + let result; + if (command === "materialize") { + result = materializeVersionFiles({ + baseSha: requiredOption(options, "base-sha"), + out: requiredOption(options, "out"), + root: requiredOption(options, "root"), + version: requiredOption(options, "version"), + }); + } else if (command === "validate-files") { + result = validateVersionFiles({ + baseRoot: requiredOption(options, "base-root"), + candidateRoot: requiredOption(options, "candidate-root"), + version: requiredOption(options, "version"), + }); + } else if (command === "validate-pr") { + const input = JSON.parse( + readRegularFile(requiredOption(options, "input")).toString("utf8"), + ); + result = validateReleasePr(input); + } else if (command === "manifest") { + result = createArtifactManifest({ + artifacts: requiredOption(options, "artifacts").split(","), + output: requiredOption(options, "output"), + root: requiredOption(options, "root"), + }); + } else { + throw new Error(`Unknown release-contract command: ${String(command)}`); + } + console.log(JSON.stringify(result)); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (isMain) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/release-contract.test.ts b/scripts/release-contract.test.ts new file mode 100644 index 0000000..505a7af --- /dev/null +++ b/scripts/release-contract.test.ts @@ -0,0 +1,270 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + assertReleaseVersion, + createArtifactManifest, + materializeVersionFiles, + validateCurrentVersionFiles, + validateReleasePr, + validateVersionFiles, +} from "./release-contract.mjs"; + +const tempRoots: string[] = []; +const BASE_SHA = "a".repeat(40); + +async function makeTempRoot(name: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), `podnotes-${name}-`)); + tempRoots.push(root); + return root; +} + +async function writeJson(filePath: string, value: unknown) { + await fs.writeFile(filePath, `${JSON.stringify(value, null, "\t")}\n`); +} + +async function writeVersionFixture(root: string, version = "2.17.3") { + await fs.mkdir(root, { recursive: true }); + await writeJson(path.join(root, "package.json"), { + name: "podnotes", + scripts: { test: "vitest" }, + version, + }); + await writeJson(path.join(root, "package-lock.json"), { + lockfileVersion: 3, + name: "podnotes", + packages: { "": { name: "podnotes", version } }, + version, + }); + await writeJson(path.join(root, "manifest.json"), { + id: "podnotes", + minAppVersion: "1.0.0", + version, + }); + await writeJson(path.join(root, "versions.json"), { [version]: "1.0.0" }); +} + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => fs.rm(root, { force: true, recursive: true })), + ); +}); + +describe("release version contract", () => { + it("validates synchronized current version metadata", async () => { + const root = await makeTempRoot("release-current"); + await writeVersionFixture(root); + expect(validateCurrentVersionFiles(root)).toEqual({ + minAppVersion: "1.0.0", + version: "2.17.3", + }); + + await writeJson(path.join(root, "versions.json"), { "2.17.3": "0.9.0" }); + expect(() => validateCurrentVersionFiles(root)).toThrow("does not record"); + }); + + it("accepts stable semantic versions only", () => { + expect(assertReleaseVersion("2.17.4")).toBe("2.17.4"); + for (const value of ["v2.17.4", "2.17", "02.17.4", "2.17.4-beta.1", "../2.17.4"]) { + expect(() => assertReleaseVersion(value)).toThrow("Invalid release version"); + } + }); + + it("materializes and validates exactly synchronized version files", async () => { + const root = await makeTempRoot("release-source"); + const out = await makeTempRoot("release-output"); + await writeVersionFixture(root); + + const metadata = materializeVersionFiles({ + baseSha: BASE_SHA, + out, + root, + version: "2.17.4", + }); + expect(metadata).toMatchObject({ baseSha: BASE_SHA, schemaVersion: 1, version: "2.17.4" }); + expect(metadata.files).toHaveLength(4); + expect(metadata.files.every((file) => file.sha256.length === 64 && file.size > 0)).toBe( + true, + ); + expect( + validateVersionFiles({ baseRoot: root, candidateRoot: out, version: "2.17.4" }), + ).toEqual({ + version: "2.17.4", + }); + + const packageJson = JSON.parse(await fs.readFile(path.join(out, "package.json"), "utf8")); + const packageLock = JSON.parse( + await fs.readFile(path.join(out, "package-lock.json"), "utf8"), + ); + const manifest = JSON.parse(await fs.readFile(path.join(out, "manifest.json"), "utf8")); + const versions = JSON.parse(await fs.readFile(path.join(out, "versions.json"), "utf8")); + expect(packageJson.version).toBe("2.17.4"); + expect(packageLock.version).toBe("2.17.4"); + expect(packageLock.packages[""].version).toBe("2.17.4"); + expect(manifest.version).toBe("2.17.4"); + expect(versions["2.17.4"]).toBe("1.0.0"); + }); + + it("rejects output reuse and an existing version entry", async () => { + const root = await makeTempRoot("release-existing"); + const out = await makeTempRoot("release-nonempty"); + await writeVersionFixture(root); + await fs.writeFile(path.join(out, "unexpected"), "x"); + expect(() => + materializeVersionFiles({ baseSha: BASE_SHA, out, root, version: "2.17.4" }), + ).toThrow("must be empty"); + + const versions = JSON.parse(await fs.readFile(path.join(root, "versions.json"), "utf8")); + versions["2.17.4"] = "1.0.0"; + await writeJson(path.join(root, "versions.json"), versions); + const cleanOut = await makeTempRoot("release-clean"); + expect(() => + materializeVersionFiles({ baseSha: BASE_SHA, out: cleanOut, root, version: "2.17.4" }), + ).toThrow("already contains"); + }); + + it("rejects a symlinked version output directory", async () => { + const root = await makeTempRoot("release-symlink-source"); + const target = await makeTempRoot("release-symlink-target"); + const parent = await makeTempRoot("release-symlink-parent"); + const output = path.join(parent, "linked-output"); + await writeVersionFixture(root); + await fs.symlink(target, output); + expect(() => + materializeVersionFiles({ + baseSha: BASE_SHA, + out: output, + root, + version: "2.17.4", + }), + ).toThrow("real directory"); + }); + + it("rejects downgrades and unsynchronized version history", async () => { + const root = await makeTempRoot("release-history"); + await writeVersionFixture(root); + const downgradeOut = await makeTempRoot("release-downgrade"); + expect(() => + materializeVersionFiles({ + baseSha: BASE_SHA, + out: downgradeOut, + root, + version: "2.17.2", + }), + ).toThrow("must be newer"); + + await writeJson(path.join(root, "versions.json"), { "2.17.3": "0.9.0" }); + const historyOut = await makeTempRoot("release-bad-history"); + expect(() => + materializeVersionFiles({ + baseSha: BASE_SHA, + out: historyOut, + root, + version: "2.17.4", + }), + ).toThrow("version history"); + }); + + it.each([ + { + file: "package.json", + mutate: (value: Record) => { + value.scripts = { test: "malicious-command" }; + }, + error: "package.json changed outside", + }, + { + file: "package-lock.json", + mutate: (value: Record) => { + value.lockfileVersion = 2; + }, + error: "package-lock.json changed outside", + }, + { + file: "manifest.json", + mutate: (value: Record) => { + value.name = "Impostor"; + }, + error: "manifest.json changed outside", + }, + ])("rejects non-version changes in $file", async ({ error, file, mutate }) => { + const root = await makeTempRoot("release-base"); + const out = await makeTempRoot("release-candidate"); + await writeVersionFixture(root); + materializeVersionFiles({ baseSha: BASE_SHA, out, root, version: "2.17.4" }); + const value = JSON.parse(await fs.readFile(path.join(out, file), "utf8")); + mutate(value); + await writeJson(path.join(out, file), value); + expect(() => + validateVersionFiles({ baseRoot: root, candidateRoot: out, version: "2.17.4" }), + ).toThrow(error); + }); + + it("rejects extra versions.json mutations", async () => { + const root = await makeTempRoot("release-version-base"); + const out = await makeTempRoot("release-version-candidate"); + await writeVersionFixture(root); + materializeVersionFiles({ baseSha: BASE_SHA, out, root, version: "2.17.4" }); + const versions = JSON.parse(await fs.readFile(path.join(out, "versions.json"), "utf8")); + versions["1.0.0"] = "0.1.0"; + await writeJson(path.join(out, "versions.json"), versions); + expect(() => + validateVersionFiles({ baseRoot: root, candidateRoot: out, version: "2.17.4" }), + ).toThrow("versions.json changed outside"); + }); +}); + +describe("release PR provenance", () => { + const validInput = { + baseRef: "master", + branch: "release/2.17.4", + changedFiles: ["versions.json", "package.json", "manifest.json", "package-lock.json"], + headRepository: "chhoumann/PodNotes", + repository: "chhoumann/PodNotes", + title: "release(version): Release 2.17.4", + version: "2.17.4", + }; + + it("accepts an exact same-repository release PR", () => { + expect(validateReleasePr(validInput)).toEqual({ version: "2.17.4" }); + }); + + it.each([ + [{ ...validInput, branch: "feature/release" }, "branch/version"], + [{ ...validInput, title: "Release 2.17.4" }, "title/version"], + [{ ...validInput, headRepository: "fork/PodNotes" }, "this repository"], + [{ ...validInput, changedFiles: [...validInput.changedFiles, "src/main.ts"] }, "exactly"], + ])("rejects invalid release PR provenance", (input, error) => { + expect(() => validateReleasePr(input)).toThrow(error); + }); +}); + +describe("release artifact manifest", () => { + it("hashes exactly the two regular release assets", async () => { + const root = await makeTempRoot("release-assets"); + await fs.writeFile(path.join(root, "main.js"), "bundle"); + await fs.writeFile(path.join(root, "manifest.json"), "manifest"); + const output = path.join(root, "metadata.json"); + const manifest = createArtifactManifest({ + artifacts: ["main.js", "manifest.json"], + output, + root, + }); + expect(manifest.artifacts).toHaveLength(2); + expect(JSON.parse(await fs.readFile(output, "utf8"))).toEqual(manifest); + }); + + it("rejects extra assets and symlinks", async () => { + const root = await makeTempRoot("release-unsafe-assets"); + await fs.writeFile(path.join(root, "real-main.js"), "bundle"); + await fs.symlink(path.join(root, "real-main.js"), path.join(root, "main.js")); + await fs.writeFile(path.join(root, "manifest.json"), "manifest"); + expect(() => + createArtifactManifest({ artifacts: ["main.js", "manifest.json"], root }), + ).toThrow("regular file"); + expect(() => + createArtifactManifest({ artifacts: ["main.js", "manifest.json", "extra.js"], root }), + ).toThrow("exactly"); + }); +}); diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs new file mode 100644 index 0000000..c7ab1fb --- /dev/null +++ b/scripts/release-plan.mjs @@ -0,0 +1,275 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { analyzeCommits } from "@semantic-release/commit-analyzer"; +import { generateNotes } from "@semantic-release/release-notes-generator"; +import { assertReleaseVersion, validateCurrentVersionFiles } from "./release-contract.mjs"; + +/** @type {Array<{ type: string; scope?: string; release: "patch" }>} */ +export const RELEASE_RULES = [ + { type: "chore", release: "patch" }, + { scope: "deps", type: "build", release: "patch" }, +]; + +const RELEASE_TYPES = new Set(["major", "minor", "patch"]); +const silentLogger = { error() {}, log() {}, success() {}, warn() {} }; + +/** @param {string} cwd @param {string[]} args */ +function git(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }).trim(); +} + +/** @param {string} version */ +function versionParts(version) { + assertReleaseVersion(version); + return version.split(".").map(Number); +} + +/** @param {string} left @param {string} right */ +function compareVersions(left, right) { + const leftParts = versionParts(left); + const rightParts = versionParts(right); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] - rightParts[index]; + } + } + return 0; +} + +/** @param {string} version @param {string} releaseType */ +export function incrementVersion(version, releaseType) { + const [major, minor, patchVersion] = versionParts(version); + if (releaseType === "major") return `${major + 1}.0.0`; + if (releaseType === "minor") return `${major}.${minor + 1}.0`; + if (releaseType === "patch") return `${major}.${minor}.${patchVersion + 1}`; + throw new Error(`Unsupported release type: ${releaseType}`); +} + +/** @param {string} cwd */ +function repositoryUrl(cwd) { + const packageJson = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); + const value = + typeof packageJson.repository === "string" + ? packageJson.repository + : packageJson.repository?.url; + if (typeof value !== "string" || !value) { + throw new Error("package.json must provide a repository URL for release notes."); + } + return value; +} + +/** @param {string} cwd @param {string | undefined} expectedVersion */ +function latestStableTag(cwd, expectedVersion) { + const output = git(cwd, ["tag", "--merged", "HEAD"]); + let tags = output + .split("\n") + .map((tag) => tag.trim()) + .filter((tag) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag)) + .sort((left, right) => compareVersions(right, left)); + if (expectedVersion) { + let expectedTagSha; + try { + expectedTagSha = execFileSync( + "git", + ["rev-parse", "--verify", `${expectedVersion}^{commit}`], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + ).trim(); + } catch { + expectedTagSha = undefined; + } + if (expectedTagSha) { + const head = git(cwd, ["rev-parse", "HEAD"]); + if (expectedTagSha !== head) { + throw new Error( + `Existing expected tag ${expectedVersion} points to ${expectedTagSha}, not HEAD ${head}.`, + ); + } + tags = tags.filter((tag) => tag !== expectedVersion); + } + } + return tags[0] ?? null; +} + +/** @param {string} cwd @param {string | null} fromTag */ +function commitsSince(cwd, fromTag) { + const range = fromTag ? `${fromTag}..HEAD` : "HEAD"; + const hashes = git(cwd, ["rev-list", range]) + .split("\n") + .map((hash) => hash.trim()) + .filter(Boolean); + return hashes.map((hash) => { + const fields = execFileSync( + "git", + ["show", "-s", "--format=%H%x00%aN%x00%aE%x00%aI%x00%cI%x00%D%x00%B", hash], + { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 }, + ).split("\0"); + if (fields.length !== 7) throw new Error(`Unable to parse release commit ${hash}.`); + const [commitHash, authorName, authorEmail, authorDate, committerDate, gitTags, message] = + fields; + return { + authorDate, + authorEmail, + authorName, + committerDate, + gitTags: gitTags.trim(), + hash: commitHash, + message: message.trim(), + }; + }); +} + +/** + * @typedef {{ + * cwd: string; + * analyzer?: typeof analyzeCommits; + * expectedVersion?: string; + * notesGenerator?: typeof generateNotes; + * }} CalculatePlanOptions + */ + +/** @param {CalculatePlanOptions} options */ +export async function calculateReleasePlan(options) { + const cwd = path.resolve(options.cwd); + const baseSha = git(cwd, ["rev-parse", "HEAD"]); + const expectedVersion = options.expectedVersion + ? assertReleaseVersion(options.expectedVersion) + : undefined; + const lastTag = latestStableTag(cwd, expectedVersion); + const currentVersion = validateCurrentVersionFiles(cwd).version; + if (expectedVersion) { + if (currentVersion !== expectedVersion) { + throw new Error( + `Synchronized version is ${currentVersion}, expected release ${expectedVersion}.`, + ); + } + } else if (!lastTag || currentVersion !== lastTag) { + throw new Error( + `Synchronized version ${currentVersion} does not match latest stable tag ${lastTag ?? "none"}.`, + ); + } + const previousVersion = lastTag ?? "0.0.0"; + const lastReleaseSha = lastTag ? git(cwd, ["rev-parse", `${lastTag}^{commit}`]) : ""; + const commits = commitsSince(cwd, lastTag); + const analyzer = options.analyzer ?? analyzeCommits; + const releaseType = await analyzer( + { releaseRules: RELEASE_RULES }, + { commits, cwd, logger: silentLogger }, + ); + + if (!releaseType) { + return { + baseSha, + notes: "", + release: false, + schemaVersion: 1, + }; + } + if (!RELEASE_TYPES.has(releaseType)) { + throw new Error(`Unsupported release type from commit analyzer: ${releaseType}`); + } + const version = incrementVersion(previousVersion, releaseType); + const releaseDate = git(cwd, ["show", "-s", "--format=%cI", "HEAD"]).slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(releaseDate)) { + throw new Error("Unable to determine a stable release date from HEAD."); + } + const notesGenerator = options.notesGenerator ?? generateNotes; + const generatedNotes = await notesGenerator( + { + writerOpts: { + finalizeContext: (context) => ({ ...context, date: releaseDate }), + }, + }, + { + commits, + cwd, + lastRelease: { + gitHead: lastReleaseSha, + gitTag: lastTag ?? "", + version: lastTag ?? "", + }, + logger: silentLogger, + nextRelease: { + gitHead: baseSha, + gitTag: version, + type: releaseType, + version, + }, + options: { repositoryUrl: repositoryUrl(cwd) }, + }, + ); + if (typeof generatedNotes !== "string") { + throw new Error("Release notes generator did not return text."); + } + const notes = generatedNotes.trim() + ? generatedNotes + : `## ${version}\n\nMaintenance release.\n`; + return { + baseSha, + nextVersion: version, + notes, + previousVersion: lastTag, + release: true, + releaseType, + schemaVersion: 1, + }; +} + +/** @param {string[]} argv */ +function parseArgs(argv) { + /** @type {{ cwd: string; expectedVersion?: string; notesOutput?: string; output?: string }} */ + const options = { cwd: process.cwd() }; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error(`Invalid release-plan argument near ${String(key)}.`); + } + if (key === "--cwd") options.cwd = value; + else if (key === "--expected-version") options.expectedVersion = value; + else if (key === "--notes-output") options.notesOutput = value; + else if (key === "--output") options.output = value; + else throw new Error(`Unknown release-plan option: ${key}`); + } + return options; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const expectedVersion = options.expectedVersion + ? assertReleaseVersion(options.expectedVersion) + : undefined; + const plan = await calculateReleasePlan({ + cwd: path.resolve(options.cwd), + expectedVersion, + }); + if (options.expectedVersion) { + if (!plan.release || plan.nextVersion !== expectedVersion) { + throw new Error( + `Release plan version is ${plan.release ? plan.nextVersion : "none"}, expected ${expectedVersion}.`, + ); + } + } + const serialized = `${JSON.stringify(plan, null, "\t")}\n`; + if (options.output) fs.writeFileSync(path.resolve(options.output), serialized, { flag: "wx" }); + if (options.notesOutput) { + fs.writeFileSync(path.resolve(options.notesOutput), plan.notes, { flag: "wx" }); + } + console.log(JSON.stringify(plan)); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (isMain) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/release-plan.test.ts b/scripts/release-plan.test.ts new file mode 100644 index 0000000..fe8aa3a --- /dev/null +++ b/scripts/release-plan.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { calculateReleasePlan, incrementVersion } from "./release-plan.mjs"; + +const tempRoots: string[] = []; + +function git(cwd: string, args: string[]) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +async function writeSynchronizedVersion(root: string, version: string) { + let versions: Record = {}; + try { + versions = JSON.parse(await fs.readFile(path.join(root, "versions.json"), "utf8")); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + versions[version] = "1.0.0"; + await Promise.all([ + fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify( + { + name: "podnotes", + repository: { url: "https://github.com/chhoumann/PodNotes.git" }, + version, + }, + null, + 2, + )}\n`, + ), + fs.writeFile( + path.join(root, "package-lock.json"), + `${JSON.stringify( + { + lockfileVersion: 3, + name: "podnotes", + packages: { "": { name: "podnotes", version } }, + version, + }, + null, + 2, + )}\n`, + ), + fs.writeFile( + path.join(root, "manifest.json"), + `${JSON.stringify({ id: "podnotes", minAppVersion: "1.0.0", version }, null, 2)}\n`, + ), + fs.writeFile(path.join(root, "versions.json"), `${JSON.stringify(versions, null, 2)}\n`), + ]); +} + +async function releaseRepository() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "podnotes-release-plan-")); + tempRoots.push(root); + git(root, ["init", "--initial-branch=master"]); + git(root, ["config", "user.name", "PodNotes Test"]); + git(root, ["config", "user.email", "podnotes@example.com"]); + await writeSynchronizedVersion(root, "2.17.3"); + git(root, ["add", "package.json", "package-lock.json", "manifest.json", "versions.json"]); + git(root, ["commit", "-m", "release(version): Release 2.17.3"]); + git(root, ["tag", "2.17.3"]); + return root; +} + +function commit(cwd: string, message: string) { + execFileSync("git", ["commit", "--allow-empty", "-m", message], { + cwd, + env: { + ...process.env, + GIT_AUTHOR_DATE: "2024-03-04T12:00:00Z", + GIT_COMMITTER_DATE: "2024-03-04T12:00:00Z", + }, + }); +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + tempRoots.splice(0).map((root) => fs.rm(root, { force: true, recursive: true })), + ); +}); + +describe("release plan", () => { + it("emits an explicit no-release plan", async () => { + const root = await releaseRepository(); + commit(root, "docs: clarify installation"); + const plan = await calculateReleasePlan({ cwd: root }); + expect(plan).toMatchObject({ notes: "", release: false, schemaVersion: 1 }); + expect(plan.baseSha).toBe(git(root, ["rev-parse", "HEAD"])); + }); + + it.each([ + ["fix: correct playback", "patch", "2.17.4"], + ["feat: add chapter navigation", "minor", "2.18.0"], + [ + "feat: replace the public API\n\nBREAKING CHANGE: consumers must use the new API", + "major", + "3.0.0", + ], + ["chore: modernize tooling", "patch", "2.17.4"], + ["build(deps): update Vite", "patch", "2.17.4"], + ] as const)("plans %s as a %s release", async (message, type, version) => { + const root = await releaseRepository(); + commit(root, message); + const plan = await calculateReleasePlan({ cwd: root }); + expect(plan).toMatchObject({ + nextVersion: version, + previousVersion: "2.17.3", + release: true, + releaseType: type, + schemaVersion: 1, + }); + expect(plan.notes.trim()).not.toBe(""); + expect(plan.notes).toContain("(2024-03-04)"); + }); + + it("ignores a release commit after the latest tag", async () => { + const root = await releaseRepository(); + commit(root, "release(version): Release 2.17.3"); + await expect(calculateReleasePlan({ cwd: root })).resolves.toMatchObject({ + release: false, + }); + }); + + it("recomputes an expected release when its recovery tag already targets HEAD", async () => { + const root = await releaseRepository(); + commit(root, "fix: correct playback"); + await writeSynchronizedVersion(root, "2.17.4"); + git(root, ["add", "package.json", "package-lock.json", "manifest.json", "versions.json"]); + commit(root, "release(version): Release 2.17.4"); + git(root, ["tag", "2.17.4"]); + await expect( + calculateReleasePlan({ cwd: root, expectedVersion: "2.17.4" }), + ).resolves.toMatchObject({ + nextVersion: "2.17.4", + previousVersion: "2.17.3", + release: true, + }); + }); + + it("rejects an expected recovery tag that does not target HEAD", async () => { + const root = await releaseRepository(); + git(root, ["checkout", "--quiet", "-b", "stray-release"]); + commit(root, "docs: create a divergent tag"); + git(root, ["tag", "2.17.4"]); + git(root, ["checkout", "--quiet", "master"]); + commit(root, "fix: correct playback"); + await expect( + calculateReleasePlan({ cwd: root, expectedVersion: "2.17.4" }), + ).rejects.toThrow("not HEAD"); + }); + + it("provides deterministic fallback notes", async () => { + const root = await releaseRepository(); + commit(root, "fix: correct playback"); + const plan = await calculateReleasePlan({ + cwd: root, + notesGenerator: vi.fn(async () => ""), + }); + expect(plan.notes).toBe("## 2.17.4\n\nMaintenance release.\n"); + }); + + it("increments stable versions and rejects unsupported release types", () => { + expect(incrementVersion("2.17.3", "patch")).toBe("2.17.4"); + expect(incrementVersion("2.17.3", "minor")).toBe("2.18.0"); + expect(incrementVersion("2.17.3", "major")).toBe("3.0.0"); + expect(() => incrementVersion("2.17.3", "prerelease")).toThrow("Unsupported"); + }); +}); diff --git a/scripts/semantic-release-plugins.d.ts b/scripts/semantic-release-plugins.d.ts new file mode 100644 index 0000000..5fc6091 --- /dev/null +++ b/scripts/semantic-release-plugins.d.ts @@ -0,0 +1,68 @@ +declare module "@semantic-release/commit-analyzer" { + type ReleaseType = "major" | "minor" | "patch"; + + type ReleaseCommit = { + authorDate: string; + authorEmail: string; + authorName: string; + committerDate: string; + gitTags: string; + hash: string; + message: string; + }; + + type ReleaseLogger = { + error(...arguments_: unknown[]): void; + log(...arguments_: unknown[]): void; + success(...arguments_: unknown[]): void; + warn(...arguments_: unknown[]): void; + }; + + export function analyzeCommits( + pluginConfig: { + releaseRules: Array<{ + release: ReleaseType; + scope?: string; + type: string; + }>; + }, + context: { + commits: ReleaseCommit[]; + cwd: string; + logger: ReleaseLogger; + }, + ): Promise; +} + +declare module "@semantic-release/release-notes-generator" { + import type { + ReleaseCommit, + ReleaseLogger, + ReleaseType, + } from "@semantic-release/commit-analyzer"; + + export function generateNotes( + pluginConfig: { + writerOpts?: { + finalizeContext(context: Record): Record; + }; + }, + context: { + commits: ReleaseCommit[]; + cwd: string; + lastRelease: { + gitHead: string; + gitTag: string; + version: string; + }; + logger: ReleaseLogger; + nextRelease: { + gitHead: string; + gitTag: string; + type: ReleaseType; + version: string; + }; + options: { repositoryUrl: string }; + }, + ): Promise; +} diff --git a/scripts/version-bump.test.ts b/scripts/version-bump.test.ts deleted file mode 100644 index 85da6a7..0000000 --- a/scripts/version-bump.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -const scriptPath = resolve(process.cwd(), "version-bump.mjs"); - -describe("version-bump", () => { - it("fails clearly before reading project files when npm_package_version is missing", () => { - const result = spawnSync(process.execPath, [scriptPath], { - cwd: tmpdir(), - encoding: "utf8", - env: { - ...process.env, - npm_package_version: "", - }, - }); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain( - "npm_package_version is required; run this script through `npm version`.", - ); - }); -}); diff --git a/version-bump.mjs b/version-bump.mjs deleted file mode 100644 index c9fb99d..0000000 --- a/version-bump.mjs +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-check - -import { readFileSync, writeFileSync } from "fs"; - -const targetVersion = process.env.npm_package_version; - -if (!targetVersion) { - throw new Error("npm_package_version is required; run this script through `npm version`."); -} - -// read minAppVersion from manifest.json and bump version to target version -let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); -const { minAppVersion } = manifest; -manifest.version = targetVersion; -// Write a trailing newline so the file stays Biome-clean (format:check). Without -// it, every release commit silently strips the newline and the next PR fails. -writeFileSync("manifest.json", `${JSON.stringify(manifest, null, "\t")}\n`); - -// update versions.json with target version and minAppVersion from manifest.json -let versions = JSON.parse(readFileSync("versions.json", "utf8")); -versions[targetVersion] = minAppVersion; -writeFileSync("versions.json", `${JSON.stringify(versions, null, "\t")}\n`);