diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf00bfa..27edec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -19,5 +21,14 @@ jobs: - run: npm ci + - name: Validate release pull request + if: startsWith(github.head_ref, 'release/') + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + run: | + git fetch --quiet origin main + node scripts/check-release-pr.js + - name: Unit tests run: npx vitest run --exclude='tests/e2e/**' diff --git a/.github/workflows/release-prep.yml b/.github/workflows/release-prep.yml index cf681a7..1fa5dd0 100644 --- a/.github/workflows/release-prep.yml +++ b/.github/workflows/release-prep.yml @@ -11,12 +11,6 @@ on: description: 'Release bullets separated with literal \n sequences' required: true type: string - base_ref: - description: 'Base branch or ref to prepare the release from' - required: false - default: 'main' - type: string - permissions: contents: write @@ -30,13 +24,13 @@ jobs: env: RELEASE_VERSION: ${{ inputs.version }} RELEASE_CHANGELOG: ${{ inputs.changelog }} - RELEASE_BASE_REF: ${{ inputs.base_ref }} + RELEASE_BASE_REF: main steps: - - name: Checkout base ref + - name: Checkout main uses: actions/checkout@v4 with: - ref: ${{ inputs.base_ref }} + ref: main fetch-depth: 0 - name: Set up Node @@ -86,3 +80,12 @@ jobs: git add manifest.json package.json package-lock.json data/version-info.json CHANGELOG.md git commit -m "chore: bump version to ${RELEASE_VERSION}" git push origin "release/${RELEASE_VERSION}" + + - name: Write release pull request summary + run: | + { + echo "## Release branch ready" + echo + echo "Open and merge the release PR before publishing:" + echo "https://github.com/${GITHUB_REPOSITORY}/compare/main...release/${RELEASE_VERSION}?expand=1" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..20ba36f --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,148 @@ +name: Release Publish + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (x.y.z)' + required: true + type: string + pull_request: + description: 'Merged release pull request number' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-publish-${{ inputs.version }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + release_sha: ${{ steps.release_gate.outputs.release_sha }} + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_PR_NUMBER: ${{ inputs.pull_request }} + + steps: + - name: Require main workflow ref + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Release Publish must be dispatched from main" + exit 1 + fi + + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate merged release pull request + id: release_gate + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/validate-release-publish.js + + - name: Run unit tests + run: npx vitest run --exclude='tests/e2e/**' + + - name: Build release packages + run: npm run release:package + + - name: Create release notes + run: node scripts/write-release-notes.js + + - name: Upload verified release assets + uses: actions/upload-artifact@v4 + with: + name: panelize-${{ inputs.version }}-publish + path: | + dist/release-${{ inputs.version }}/panelize-${{ inputs.version }}-release.zip + dist/release-${{ inputs.version }}/panelize-${{ inputs.version }}-cws.zip + dist/release-${{ inputs.version }}/release-notes.md + if-no-files-found: error + retention-days: 14 + + publish: + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SHA: ${{ needs.validate.outputs.release_sha }} + + steps: + - name: Checkout validated release commit + uses: actions/checkout@v4 + with: + ref: ${{ needs.validate.outputs.release_sha }} + fetch-depth: 0 + + - name: Download verified release assets + uses: actions/download-artifact@v4 + with: + name: panelize-${{ inputs.version }}-publish + path: dist/release-${{ inputs.version }} + + - name: Revalidate release target + run: | + git fetch --quiet origin main + git merge-base --is-ancestor "$RELEASE_SHA" origin/main + + TAG="v${RELEASE_VERSION}" + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists" + exit 1 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub Release ${TAG} already exists" + exit 1 + fi + + - name: Create annotated release tag + run: | + TAG="v${RELEASE_VERSION}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" "$RELEASE_SHA" -m "Panelize ${RELEASE_VERSION}" + git push origin "refs/tags/${TAG}" + + - name: Create GitHub Release + run: | + TAG="v${RELEASE_VERSION}" + RELEASE_DIR="dist/release-${RELEASE_VERSION}" + gh release create "$TAG" \ + "${RELEASE_DIR}/panelize-${RELEASE_VERSION}-release.zip" \ + "${RELEASE_DIR}/panelize-${RELEASE_VERSION}-cws.zip" \ + --verify-tag \ + --title "Panelize ${RELEASE_VERSION}" \ + --notes-file "${RELEASE_DIR}/release-notes.md" + + - name: Write publish summary + run: | + { + echo "## Panelize ${RELEASE_VERSION} published" + echo + echo "Tag: v${RELEASE_VERSION}" + echo "Commit: ${RELEASE_SHA}" + echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${RELEASE_VERSION}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/modules/version-checker.js b/modules/version-checker.js index da82487..f75b693 100644 --- a/modules/version-checker.js +++ b/modules/version-checker.js @@ -2,8 +2,27 @@ // Checks for updates by comparing manifest version with GitHub import { t } from './i18n.js'; +import { compareVersions } from './version-utils.js'; -const GITHUB_MANIFEST_URL = 'https://raw.githubusercontent.com/Manho/Panelize/main/manifest.json'; +const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/Manho/Panelize/releases/latest'; +const GITHUB_RELEASES_URL = 'https://github.com/Manho/Panelize/releases/latest'; + +/** + * @typedef {Object} LatestReleaseInfo + * @property {string} version + * @property {string} releaseUrl + * @property {string} downloadUrl + */ + +/** + * @typedef {Object} UpdateCheckResult + * @property {boolean} updateAvailable + * @property {string|null} currentVersion + * @property {string|null} latestVersion + * @property {string} releaseUrl + * @property {string} downloadUrl + * @property {string|null} error + */ /** * Load local manifest version @@ -23,14 +42,16 @@ export async function loadVersionInfo() { } /** - * Fetch latest manifest from GitHub - * @returns {Promise} Latest manifest or null on error + * Fetch the latest published release from GitHub. + * + * @returns {Promise} Latest release or null on error. */ -export async function fetchLatestManifest() { +export async function fetchLatestRelease() { try { - const response = await fetch(GITHUB_MANIFEST_URL, { + const response = await fetch(GITHUB_LATEST_RELEASE_URL, { headers: { - 'Accept': 'application/json' + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' } }); @@ -38,82 +59,77 @@ export async function fetchLatestManifest() { throw new Error(`GitHub fetch error: ${response.status}`); } - const manifest = await response.json(); - return manifest; + const release = await response.json(); + const versionMatch = /^v?(\d+\.\d+\.\d+)$/.exec(release.tag_name ?? ''); + + if (!versionMatch || typeof release.html_url !== 'string') { + throw new Error('GitHub release metadata is invalid'); + } + + const version = versionMatch[1]; + const expectedAssetName = `panelize-${version}-release.zip`; + const releaseAsset = Array.isArray(release.assets) + ? release.assets.find(asset => asset?.name === expectedAssetName) + : null; + + return { + version, + releaseUrl: release.html_url, + downloadUrl: releaseAsset?.browser_download_url || release.html_url + }; } catch (error) { - console.error('Error fetching latest manifest:', error); + console.error('Error fetching latest GitHub release:', error); return null; } } -/** - * Compare two version strings (e.g., "1.0.0" vs "1.1.0") - * @param {string} current - Current version - * @param {string} latest - Latest version - * @returns {number} -1 if current < latest, 0 if equal, 1 if current > latest - */ -function compareVersions(current, latest) { - const currentParts = current.split('.').map(Number); - const latestParts = latest.split('.').map(Number); - - const maxLength = Math.max(currentParts.length, latestParts.length); - - for (let i = 0; i < maxLength; i++) { - const currentPart = currentParts[i] || 0; - const latestPart = latestParts[i] || 0; - - if (currentPart < latestPart) return -1; - if (currentPart > latestPart) return 1; - } - - return 0; -} - /** * Check if an update is available - * @returns {Promise} Update status + * @returns {Promise} Update status. */ export async function checkForUpdates() { const localInfo = await loadVersionInfo(); if (!localInfo) { return { updateAvailable: false, + currentVersion: null, + latestVersion: null, + releaseUrl: GITHUB_RELEASES_URL, + downloadUrl: GITHUB_RELEASES_URL, error: t('errVersionInfoFailed') }; } - const latestManifest = await fetchLatestManifest(); - if (!latestManifest) { + const latestRelease = await fetchLatestRelease(); + if (!latestRelease) { return { updateAvailable: false, currentVersion: localInfo.version, - error: t('errGitHubFetchFailed') + latestVersion: null, + releaseUrl: GITHUB_RELEASES_URL, + downloadUrl: GITHUB_RELEASES_URL, + error: t('msgCheckUpdatesFailed') }; } - const comparison = compareVersions(localInfo.version, latestManifest.version); + const comparison = compareVersions(localInfo.version, latestRelease.version); const updateAvailable = comparison < 0; return { updateAvailable, currentVersion: localInfo.version, - latestVersion: latestManifest.version, + latestVersion: latestRelease.version, + releaseUrl: latestRelease.releaseUrl, + downloadUrl: latestRelease.downloadUrl, error: null }; } /** - * Get the download URL for the latest version - * @returns {string} GitHub zip download URL - */ -export function getDownloadUrl() { - return 'https://github.com/Manho/Panelize/archive/refs/heads/main.zip'; -} - -/** - * Get the GitHub repository URL - * @returns {string} GitHub repository URL + * Get the latest releases page URL. + * + * @returns {string} GitHub releases URL. */ -export function getRepositoryUrl() { - return 'https://github.com/Manho/Panelize'; +export function getReleasesUrl() { + return GITHUB_RELEASES_URL; } diff --git a/modules/version-utils.js b/modules/version-utils.js new file mode 100644 index 0000000..ff469f6 --- /dev/null +++ b/modules/version-utils.js @@ -0,0 +1,27 @@ +/** + * Compare two numeric semantic versions. + * + * @param {string} current Current version. + * @param {string} next Candidate version. + * @returns {number} -1 when current is older, 0 when equal, and 1 when newer. + */ +export function compareVersions(current, next) { + const currentParts = current.split('.').map(Number); + const nextParts = next.split('.').map(Number); + const maxLength = Math.max(currentParts.length, nextParts.length); + + for (let index = 0; index < maxLength; index += 1) { + const currentPart = currentParts[index] ?? 0; + const nextPart = nextParts[index] ?? 0; + + if (currentPart < nextPart) { + return -1; + } + + if (currentPart > nextPart) { + return 1; + } + } + + return 0; +} diff --git a/options/options.html b/options/options.html index 988632f..33f25a2 100644 --- a/options/options.html +++ b/options/options.html @@ -394,7 +394,7 @@

About

- file.trim()) + .filter(Boolean); + + assertReleaseChangedFiles(changedFiles); + assertReleaseMetadata( + await getVersionState(repoRoot), + await readText(repoRoot, 'CHANGELOG.md'), + version + ); + + console.log(`[release-pr] Validated ${headRef} -> main`); +} + +main().catch(error => { + console.error(`[release-pr] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/release-gate.js b/scripts/release-gate.js new file mode 100644 index 0000000..8f390d3 --- /dev/null +++ b/scripts/release-gate.js @@ -0,0 +1,162 @@ +import { + assertVersionStateConsistency, + BUMP_COMMIT_FILES, + isValidSemver +} from './release-utils.js'; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Extract and validate the version encoded in a release branch name. + * + * @param {string} branchName Release branch name. + * @returns {string} Release version. + */ +export function getReleaseVersionFromBranch(branchName) { + const match = /^release\/(\d+\.\d+\.\d+)$/.exec(branchName); + if (!match) { + throw new Error(`Release branch must match release/x.y.z. Found "${branchName}"`); + } + + return match[1]; +} + +/** + * Require a release PR to change exactly the configured metadata files. + * + * @param {string[]} changedFiles Files changed by the pull request. + * @param {string[]} allowedFiles Configured release metadata files. + */ +export function assertReleaseChangedFiles(changedFiles, allowedFiles = BUMP_COMMIT_FILES) { + const actual = Array.from(new Set(changedFiles)).sort(); + const expected = Array.from(new Set(allowedFiles)).sort(); + const unexpected = actual.filter(file => !expected.includes(file)); + const missing = expected.filter(file => !actual.includes(file)); + + if (unexpected.length > 0 || missing.length > 0) { + const details = []; + if (unexpected.length > 0) { + details.push(`unexpected: ${unexpected.join(', ')}`); + } + if (missing.length > 0) { + details.push(`missing: ${missing.join(', ')}`); + } + throw new Error(`Release PR must change exactly the configured metadata files (${details.join('; ')})`); + } +} + +/** + * Validate the release version files and changelog entry. + * + * @param {Object} versionState Parsed version file values. + * @param {string} changelog Changelog contents. + * @param {string} targetVersion Expected release version. + */ +export function assertReleaseMetadata(versionState, changelog, targetVersion) { + if (!isValidSemver(targetVersion)) { + throw new Error(`Invalid release version "${targetVersion}". Expected x.y.z`); + } + + const currentVersion = assertVersionStateConsistency(versionState); + if (currentVersion !== targetVersion) { + throw new Error(`Release metadata version ${currentVersion} does not match target ${targetVersion}`); + } + + const headingPattern = new RegExp(`^## ${escapeRegExp(targetVersion)} - \\d{4}-\\d{2}-\\d{2}$`, 'm'); + if (!headingPattern.test(changelog)) { + throw new Error(`CHANGELOG.md is missing a dated ${targetVersion} section`); + } +} + +/** + * Validate the GitHub pull request used for a release. + * + * @param {Object} pullRequest GitHub pull request response. + * @param {string} targetVersion Expected release version. + * @returns {string} Merge commit SHA. + */ +export function assertMergedReleasePullRequest(pullRequest, targetVersion) { + const expectedHead = `release/${targetVersion}`; + + if (pullRequest?.head?.ref !== expectedHead) { + throw new Error(`Release PR head must be ${expectedHead}`); + } + if (pullRequest?.base?.ref !== 'main') { + throw new Error('Release PR base must be main'); + } + if (!pullRequest.merged_at) { + throw new Error('Release PR must be merged before publishing'); + } + if (typeof pullRequest.merge_commit_sha !== 'string' || pullRequest.merge_commit_sha.length === 0) { + throw new Error('Release PR is missing its merge commit SHA'); + } + + return pullRequest.merge_commit_sha; +} + +/** + * Require the merged release PR commit to remain in the current main history. + * + * @param {string} mergeCommitSha Release PR merge commit. + * @param {string} mainCommitSha Current main commit. + * @param {boolean} isAncestor Whether the release merge commit is an ancestor of main. + */ +export function assertReleaseCommitIsInMainHistory(mergeCommitSha, mainCommitSha, isAncestor) { + if (!isAncestor) { + throw new Error( + `Release PR merge commit ${mergeCommitSha} is not in main history at ${mainCommitSha}` + ); + } +} + +/** + * Ensure the release tag and GitHub Release do not exist yet. + * + * @param {{tagExists: boolean, releaseExists: boolean}} state Existing target state. + * @param {string} version Target release version. + */ +export function assertReleaseTargetsMissing(state, version) { + if (state.tagExists) { + throw new Error(`Tag v${version} already exists`); + } + if (state.releaseExists) { + throw new Error(`GitHub Release v${version} already exists`); + } +} + +/** + * Extract the Markdown section for a release version. + * + * @param {string} changelog Changelog contents. + * @param {string} version Release version. + * @returns {string} Release notes Markdown. + */ +export function extractChangelogSection(changelog, version) { + const headingPattern = new RegExp(`^## ${escapeRegExp(version)} - \\d{4}-\\d{2}-\\d{2}$`, 'm'); + const match = headingPattern.exec(changelog); + if (!match) { + throw new Error(`CHANGELOG.md is missing a dated ${version} section`); + } + + const sectionStart = match.index; + const nextHeadingIndex = changelog.indexOf('\n## ', sectionStart + match[0].length); + return changelog.slice(sectionStart, nextHeadingIndex === -1 ? undefined : nextHeadingIndex).trim() + '\n'; +} + +/** + * Build GitHub Release notes, including the store package name. + * + * @param {string} changelog Changelog contents. + * @param {string} version Release version. + * @returns {string} GitHub Release notes. + */ +export function buildReleaseNotes(changelog, version) { + return [ + extractChangelogSection(changelog, version).trimEnd(), + '', + `Chrome Web Store package: \`panelize-${version}-cws.zip\``, + '' + ].join('\n'); +} diff --git a/scripts/release-utils.js b/scripts/release-utils.js index 9fcf3c8..3f0128a 100644 --- a/scripts/release-utils.js +++ b/scripts/release-utils.js @@ -1,6 +1,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { execFileSync, spawnSync } from 'node:child_process'; +import { compareVersions } from '../modules/version-utils.js'; + +export { compareVersions }; export const VERSION_FILE_PATHS = { manifest: 'manifest.json', @@ -73,27 +76,6 @@ export function isValidSemver(version) { return /^\d+\.\d+\.\d+$/.test(version); } -export function compareVersions(current, next) { - const currentParts = current.split('.').map(Number); - const nextParts = next.split('.').map(Number); - const maxLength = Math.max(currentParts.length, nextParts.length); - - for (let index = 0; index < maxLength; index += 1) { - const currentPart = currentParts[index] ?? 0; - const nextPart = nextParts[index] ?? 0; - - if (currentPart < nextPart) { - return -1; - } - - if (currentPart > nextPart) { - return 1; - } - } - - return 0; -} - export function parseChangelogEntries(rawChangelog) { return rawChangelog .replace(/\\n/g, '\n') diff --git a/scripts/validate-release-publish.js b/scripts/validate-release-publish.js new file mode 100644 index 0000000..b2ae765 --- /dev/null +++ b/scripts/validate-release-publish.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node + +import { appendFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + BUMP_COMMIT_FILES, + getVersionState, + isValidSemver, + readText +} from './release-utils.js'; +import { + assertMergedReleasePullRequest, + assertReleaseChangedFiles, + assertReleaseCommitIsInMainHistory, + assertReleaseMetadata, + assertReleaseTargetsMissing +} from './release-gate.js'; + +async function githubRequest(repository, path, token, { allowNotFound = false } = {}) { + const response = await fetch(`https://api.github.com/repos/${repository}${path}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + if (allowNotFound && response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`GitHub API request failed (${response.status}) for ${path}`); + } + + return response.json(); +} + +function isCommitAncestor(repoRoot, ancestor, descendant) { + const result = spawnSync('git', ['merge-base', '--is-ancestor', ancestor, descendant], { + cwd: repoRoot, + stdio: 'ignore' + }); + return result.status === 0; +} + +function assertMetadataMatchesPullRequest(repoRoot, pullNumber, expectedHeadSha) { + execFileSync('git', ['fetch', '--quiet', 'origin', `pull/${pullNumber}/head`], { cwd: repoRoot }); + const fetchedHeadSha = execFileSync('git', ['rev-parse', 'FETCH_HEAD'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim(); + + if (fetchedHeadSha !== expectedHeadSha) { + throw new Error(`Fetched PR head ${fetchedHeadSha} does not match GitHub PR head ${expectedHeadSha}`); + } + + const comparison = spawnSync( + 'git', + ['diff', '--quiet', fetchedHeadSha, 'HEAD', '--', ...BUMP_COMMIT_FILES], + { cwd: repoRoot, stdio: 'ignore' } + ); + if (comparison.status !== 0) { + throw new Error('Release metadata on main does not match the merged release PR'); + } +} + +async function main() { + const repoRoot = process.cwd(); + const version = process.env.RELEASE_VERSION || ''; + const pullNumber = Number(process.env.RELEASE_PR_NUMBER); + const repository = process.env.GITHUB_REPOSITORY || ''; + const token = process.env.GITHUB_TOKEN || ''; + + if (!isValidSemver(version)) { + throw new Error(`Invalid release version "${version}". Expected x.y.z`); + } + if (!Number.isInteger(pullNumber) || pullNumber <= 0) { + throw new Error('RELEASE_PR_NUMBER must be a positive integer'); + } + if (!/^[^/]+\/[^/]+$/.test(repository)) { + throw new Error('GITHUB_REPOSITORY must use owner/repository format'); + } + if (!token) { + throw new Error('GITHUB_TOKEN is required'); + } + + const pullRequest = await githubRequest(repository, `/pulls/${pullNumber}`, token); + const mergeCommitSha = assertMergedReleasePullRequest(pullRequest, version); + const pullFiles = await githubRequest(repository, `/pulls/${pullNumber}/files?per_page=100`, token); + const releaseSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim(); + + if (pullRequest.changed_files > 100) { + throw new Error('Release PR contains more than 100 changed files'); + } + assertReleaseChangedFiles(pullFiles.map(file => file.filename)); + assertReleaseCommitIsInMainHistory( + mergeCommitSha, + releaseSha, + isCommitAncestor(repoRoot, mergeCommitSha, releaseSha) + ); + assertMetadataMatchesPullRequest(repoRoot, pullNumber, pullRequest.head.sha); + assertReleaseMetadata( + await getVersionState(repoRoot), + await readText(repoRoot, 'CHANGELOG.md'), + version + ); + + const tag = `v${version}`; + const [tagReference, release] = await Promise.all([ + githubRequest(repository, `/git/ref/tags/${encodeURIComponent(tag)}`, token, { allowNotFound: true }), + githubRequest(repository, `/releases/tags/${encodeURIComponent(tag)}`, token, { allowNotFound: true }) + ]); + assertReleaseTargetsMissing({ tagExists: Boolean(tagReference), releaseExists: Boolean(release) }, version); + + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `release_sha=${releaseSha}\n`); + } + + console.log(`[release-publish] Validated PR #${pullNumber} for v${version} at ${releaseSha}`); +} + +main().catch(error => { + console.error(`[release-publish] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/write-release-notes.js b/scripts/write-release-notes.js new file mode 100644 index 0000000..7ea13a9 --- /dev/null +++ b/scripts/write-release-notes.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { isValidSemver, readText } from './release-utils.js'; +import { buildReleaseNotes } from './release-gate.js'; + +async function main() { + const repoRoot = process.cwd(); + const version = process.env.RELEASE_VERSION || ''; + + if (!isValidSemver(version)) { + throw new Error(`Invalid release version "${version}". Expected x.y.z`); + } + + const outputPath = path.join(repoRoot, 'dist', `release-${version}`, 'release-notes.md'); + const changelog = await readText(repoRoot, 'CHANGELOG.md'); + await fs.writeFile(outputPath, buildReleaseNotes(changelog, version)); + console.log(`[release-notes] Created ${outputPath}`); +} + +main().catch(error => { + console.error(`[release-notes] ${error.message}`); + process.exit(1); +}); diff --git a/tests/release-gate.test.js b/tests/release-gate.test.js new file mode 100644 index 0000000..f92de08 --- /dev/null +++ b/tests/release-gate.test.js @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { BUMP_COMMIT_FILES } from '../scripts/release-utils.js'; +import { + assertMergedReleasePullRequest, + assertReleaseChangedFiles, + assertReleaseCommitIsInMainHistory, + assertReleaseMetadata, + assertReleaseTargetsMissing, + buildReleaseNotes, + extractChangelogSection, + getReleaseVersionFromBranch +} from '../scripts/release-gate.js'; + +const versionState = { + manifest: '1.2.6', + packageJson: '1.2.6', + packageLock: '1.2.6', + packageLockRootPackage: '1.2.6', + versionInfo: '1.2.6' +}; + +function createPullRequest(overrides = {}) { + return { + head: { ref: 'release/1.2.6', sha: 'head-sha' }, + base: { ref: 'main' }, + merged_at: '2026-07-17T00:00:00Z', + merge_commit_sha: 'merge-sha', + ...overrides + }; +} + +describe('release gate', () => { + it('extracts a version from a release branch', () => { + expect(getReleaseVersionFromBranch('release/1.2.6')).toBe('1.2.6'); + expect(() => getReleaseVersionFromBranch('fix/release')).toThrow(/release\/x\.y\.z/); + }); + + it('requires exactly the configured release metadata files', () => { + expect(() => assertReleaseChangedFiles([...BUMP_COMMIT_FILES])).not.toThrow(); + expect(() => assertReleaseChangedFiles([...BUMP_COMMIT_FILES, 'options/options.js'])) + .toThrow(/unexpected/); + expect(() => assertReleaseChangedFiles(BUMP_COMMIT_FILES.slice(1))) + .toThrow(/missing/); + }); + + it('validates synchronized release metadata and changelog', () => { + const changelog = '# Changelog\n\n## 1.2.6 - 2026-07-17\n- Fixed updates\n'; + expect(() => assertReleaseMetadata(versionState, changelog, '1.2.6')).not.toThrow(); + expect(() => assertReleaseMetadata( + { ...versionState, manifest: '1.2.5' }, + changelog, + '1.2.6' + )).toThrow(/out of sync/); + expect(() => assertReleaseMetadata(versionState, '# Changelog\n', '1.2.6')) + .toThrow(/missing/); + }); + + it('requires a merged release PR targeting main', () => { + expect(assertMergedReleasePullRequest(createPullRequest(), '1.2.6')).toBe('merge-sha'); + expect(() => assertMergedReleasePullRequest( + createPullRequest({ merged_at: null }), + '1.2.6' + )).toThrow(/must be merged/); + expect(() => assertMergedReleasePullRequest( + createPullRequest({ head: { ref: 'release/1.2.5' } }), + '1.2.6' + )).toThrow(/head must be/); + expect(() => assertMergedReleasePullRequest( + createPullRequest({ base: { ref: 'develop' } }), + '1.2.6' + )).toThrow(/base must be main/); + }); + + it('requires the release PR merge to remain in main history', () => { + expect(() => assertReleaseCommitIsInMainHistory('merge-sha', 'newer-sha', true)).not.toThrow(); + expect(() => assertReleaseCommitIsInMainHistory('merge-sha', 'newer-sha', false)) + .toThrow(/not in main history/); + }); + + it('rejects existing release targets', () => { + expect(() => assertReleaseTargetsMissing({ tagExists: false, releaseExists: false }, '1.2.6')) + .not.toThrow(); + expect(() => assertReleaseTargetsMissing({ tagExists: true, releaseExists: false }, '1.2.6')) + .toThrow(/Tag v1\.2\.6 already exists/); + expect(() => assertReleaseTargetsMissing({ tagExists: false, releaseExists: true }, '1.2.6')) + .toThrow(/GitHub Release v1\.2\.6 already exists/); + }); + + it('extracts only the requested changelog section', () => { + const changelog = [ + '# Changelog', + '', + '## 1.2.6 - 2026-07-17', + '- Fixed updates', + '', + '## 1.2.5 - 2026-07-16', + '- Previous release', + '' + ].join('\n'); + + expect(extractChangelogSection(changelog, '1.2.6')).toBe( + '## 1.2.6 - 2026-07-17\n- Fixed updates\n' + ); + expect(buildReleaseNotes(changelog, '1.2.6')).toContain( + 'Chrome Web Store package: `panelize-1.2.6-cws.zip`' + ); + }); +}); diff --git a/tests/version-checker.test.js b/tests/version-checker.test.js new file mode 100644 index 0000000..5ae9e71 --- /dev/null +++ b/tests/version-checker.test.js @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + checkForUpdates, + fetchLatestRelease, + getReleasesUrl +} from '../modules/version-checker.js'; + +const RELEASE_URL = 'https://github.com/Manho/Panelize/releases/tag/v1.2.6'; +const DOWNLOAD_URL = 'https://github.com/Manho/Panelize/releases/download/v1.2.6/panelize-1.2.6-release.zip'; + +function createRelease(overrides = {}) { + return { + tag_name: 'v1.2.6', + html_url: RELEASE_URL, + assets: [ + { + name: 'panelize-1.2.6-release.zip', + browser_download_url: DOWNLOAD_URL + } + ], + ...overrides + }; +} + +function mockReleaseResponse(release = createRelease()) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(release) + }); +} + +describe('version checker', () => { + beforeEach(() => { + vi.restoreAllMocks(); + chrome.runtime.getManifest = vi.fn(() => ({ version: '1.2.5' })); + }); + + it('returns the latest release asset when an update is available', async () => { + mockReleaseResponse(); + + await expect(checkForUpdates()).resolves.toEqual({ + updateAvailable: true, + currentVersion: '1.2.5', + latestVersion: '1.2.6', + releaseUrl: RELEASE_URL, + downloadUrl: DOWNLOAD_URL, + error: null + }); + expect(fetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/Manho/Panelize/releases/latest', + expect.objectContaining({ + headers: expect.objectContaining({ Accept: 'application/vnd.github+json' }) + }) + ); + }); + + it.each([ + ['1.2.6', false], + ['1.2.7', false] + ])('does not offer an update to local version %s', async (localVersion, updateAvailable) => { + chrome.runtime.getManifest = vi.fn(() => ({ version: localVersion })); + mockReleaseResponse(); + + const result = await checkForUpdates(); + + expect(result.updateAvailable).toBe(updateAvailable); + expect(result.latestVersion).toBe('1.2.6'); + }); + + it('falls back to the release page when the expected asset is missing', async () => { + mockReleaseResponse(createRelease({ assets: [] })); + + await expect(fetchLatestRelease()).resolves.toEqual({ + version: '1.2.6', + releaseUrl: RELEASE_URL, + downloadUrl: RELEASE_URL + }); + }); + + it('rejects an invalid release tag', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockReleaseResponse(createRelease({ tag_name: 'latest' })); + + await expect(fetchLatestRelease()).resolves.toBeNull(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('returns an error when GitHub responds unsuccessfully', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 }); + + const result = await checkForUpdates(); + + expect(result).toMatchObject({ + updateAvailable: false, + currentVersion: '1.2.5', + latestVersion: null, + releaseUrl: getReleasesUrl(), + downloadUrl: getReleasesUrl() + }); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/tests/version-utils.test.js b/tests/version-utils.test.js new file mode 100644 index 0000000..fbddddf --- /dev/null +++ b/tests/version-utils.test.js @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { compareVersions } from '../modules/version-utils.js'; + +describe('version utils', () => { + it('compares numeric semantic versions', () => { + expect(compareVersions('1.2.5', '1.2.6')).toBe(-1); + expect(compareVersions('1.2.6', '1.2.6')).toBe(0); + expect(compareVersions('1.3.0', '1.2.6')).toBe(1); + }); +});