diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 03409a8..47968f5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,7 +10,9 @@ Link an issue or the relevant OGraf v1 specification section when available. - [ ] I ran the checks that cover this change. - [ ] I added or updated tests when behavior changed. -- [ ] I updated the correct app or core changelog when users are affected. +- [ ] I checked the release impact. Release-relevant changes include the + affected package version, lockfile version, and changelog entry; otherwise + this item is not applicable. - [ ] I kept package code inside the isolated preview sandbox. - [ ] I included screenshots for visible UI changes. - [ ] I did not include private packages, credentials, or licensed assets. diff --git a/.github/workflows/publish-core.yml b/.github/workflows/publish-core.yml new file mode 100644 index 0000000..6502365 --- /dev/null +++ b/.github/workflows/publish-core.yml @@ -0,0 +1,87 @@ +name: Validate release and publish core + +on: + release: + types: + - published + +permissions: + contents: read + +concurrency: + group: publish-core-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + validate-release: + name: Validate release metadata + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.x + package-manager-cache: false + + - name: Verify release commit is on master + run: | + git fetch --no-tags origin master:refs/remotes/origin/master + git merge-base --is-ancestor HEAD origin/master + + - name: Reject prerelease + if: github.event.release.prerelease + run: | + echo "Prerelease tags are not supported." + exit 1 + + - name: Validate release tag + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: node scripts/check-release-tag.mjs + + publish: + name: Publish core to npm + needs: validate-release + if: startsWith(github.event.release.tag_name, 'core-v') + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read + id-token: write + + steps: + - name: Check out release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.x + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + - name: Install Chrome for Playwright + run: npx playwright install --with-deps chrome + + - name: Run release checks + run: npm run release:check + + - name: Publish package + run: >- + npm publish --workspace=packages/validator-core --access public + --registry=https://registry.npmjs.org/ diff --git a/CHANGELOG.md b/CHANGELOG.md index acf43cd..93d832f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,3 +4,4 @@ The app and validator core are versioned and released independently. - [OGraf Validator App](packages/app/CHANGELOG.md) - [Validator Core](packages/validator-core/CHANGELOG.md) +- [Release process and tag conventions](RELEASING.md) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d1b688..d00b64b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,8 @@ Thanks for helping improve OGraf Validator. ## Development setup -You need Node.js 24 or newer, npm 11 or newer, and Google Chrome for browser -tests. +You need Node.js 24 or newer, npm 11.5.1 or newer, and Google Chrome for +browser tests. The project runs `tsc` with TypeScript 7. ESLint currently uses the TypeScript 6 compiler API through a separate package alias. Keep both root dependencies @@ -45,7 +45,9 @@ The app runs at `http://localhost:3000`. `allow-same-origin` or expose directory handles to a Graphic. - Add tests for changes to validation, scanning, preview transport, runtime behavior, or package readiness. -- Add a changelog entry to the app or core changelog when users are affected. +- Follow the [release guide](RELEASING.md) for version changes and tags. +- Include the affected package version, lockfile version, and changelog entry + in every release-relevant pull request. ### Specification changes @@ -79,6 +81,7 @@ the installed core-package smoke test, Playwright against the production - Keep generated files in the same commit as the source change that produced them. - Do not mix unrelated cleanup into a feature or bug fix. +- Do not move or reuse release tags. By submitting a contribution, you agree that it may be distributed under the [MIT License](LICENSE). diff --git a/README.md b/README.md index 9dc79fe..1455c79 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ standalone validator. Requirements: - Node.js 24 or newer -- npm 11 or newer +- npm 11.5.1 or newer - Google Chrome for Playwright tests The repository runs `tsc` with TypeScript 7. ESLint uses the compatible @@ -194,6 +194,7 @@ The app and core library are versioned separately: - [OGraf Validator app changelog](packages/app/CHANGELOG.md) - [Validator core changelog](packages/validator-core/CHANGELOG.md) +- [Release process and tag conventions](RELEASING.md) ## Contributing diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b1673c0 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,86 @@ +# Releasing OGraf Validator + +The app and validator core have separate versions and changelogs. There is no +shared repository version. + +## Release tags + +Use these exact stable SemVer tag formats: + +- App: `app-vX.Y.Z` +- Validator core and npm package: `core-vX.Y.Z` + +For example, app version `0.3.0` uses `app-v0.3.0`, while core version `0.2.1` +uses `core-v0.2.1`. + +The tag version must match all of these files: + +- the affected package's `package.json` +- the affected workspace entry in `package-lock.json` +- one dated `## X.Y.Z - YYYY-MM-DD` heading in the affected changelog + +Prerelease tags are not supported yet. Do not create a root `vX.Y.Z` tag. Never +move, delete, or reuse a tag or package version after it has been published. + +## Release pull requests + +A pull request with release-relevant app or core changes must include: + +1. the version update for the affected package; +2. the matching workspace version in `package-lock.json`; +3. the release notes in the affected changelog. + +Include these changes in the same pull request as the released behavior. Do not +add them after the pull request has been merged or the app has been deployed. +Documentation, tests, and repository infrastructure that do not change a +released app or package do not need a version bump. + +Run the full release gate before merging: + +```bash +npm ci +npm run release:check +``` + +## Create a release + +1. Merge the release pull request into `master` after all required checks pass. +2. Create a new GitHub release from the final `master` commit. +3. Use the exact app or core tag described above. +4. Copy the matching changelog section into the GitHub release notes. +5. Publish the GitHub release. Do not create it as a prerelease. + +You can check the release metadata locally before publishing: + +```bash +node scripts/check-release-tag.mjs app-v0.3.0 +node scripts/check-release-tag.mjs core-v0.2.1 +``` + +## Core package publishing + +Publishing any GitHub release starts `.github/workflows/publish-core.yml` to +check the tag convention and confirm that the tagged commit is part of +`master`. For a valid `core-vX.Y.Z` release, the workflow also: + +1. checks the tag, package version, lockfile, and changelog; +2. runs the complete release gate from the tagged commit; +3. publishes `@streamshapers/ograf-validator-core` to npm through OpenID + Connect (OIDC). + +The workflow does not use a long-lived npm token. npm creates provenance for +the public package automatically. An `app-vX.Y.Z` release never publishes an +npm package. + +The npm package must have this trusted publisher configuration: + +- Provider: GitHub Actions +- Organization: `Streamshapers` +- Repository: `OGraf-Validator` +- Workflow filename: `publish-core.yml` +- Environment: leave empty +- Allowed action: `npm publish` + +After the first automated publish succeeds, set the npm package's publishing +access to require two-factor authentication and disallow traditional publish +tokens. diff --git a/package-lock.json b/package-lock.json index f721c2e..f865fe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ }, "engines": { "node": ">=24.0.0", - "npm": ">=11.0.0" + "npm": ">=11.5.1" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index b3648ba..5f7e683 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "build": "npm run build:core && npm run build:app", "build:core": "npm run build --workspace=packages/validator-core", "build:app": "npm run build --workspace=packages/app", - "test": "npm run build:core && npm run test:workspaces", + "test": "npm run build:core && npm run test:release-tags && npm run test:workspaces", "test:workspaces": "npm run test --workspaces --if-present", + "test:release-tags": "node --test scripts/check-release-tag.test.mjs", "test:core": "npm run test --workspace=packages/validator-core", "test:e2e": "npm run test:e2e --workspace=packages/app", "smoke:core": "npm run build:core && node scripts/core-package-smoke.mjs", @@ -20,14 +21,14 @@ "spec:check": "npm run spec:check --workspace=packages/validator-core", "audit": "npm audit --audit-level=low", "audit:prod": "npm audit --omit=dev --audit-level=low", - "release:check": "npm run smoke:toolchain && npm run lint && npm run build:core && npm run typecheck:workspaces && npm run test:workspaces && npm run spec:check && npm run build:app && npm run smoke:core && npm run test:e2e && npm run audit:prod && npm run audit", + "release:check": "npm run smoke:toolchain && npm run test:release-tags && npm run lint && npm run build:core && npm run typecheck:workspaces && npm run test:workspaces && npm run spec:check && npm run build:app && npm run smoke:core && npm run test:e2e && npm run audit:prod && npm run audit", "lint": "eslint packages scripts", "typecheck": "npm run build:core && npm run typecheck:workspaces", "typecheck:workspaces": "npm run typecheck --workspaces --if-present" }, "engines": { "node": ">=24.0.0", - "npm": ">=11.0.0" + "npm": ">=11.5.1" }, "repository": { "type": "git", diff --git a/scripts/check-release-tag.mjs b/scripts/check-release-tag.mjs new file mode 100644 index 0000000..d7d6267 --- /dev/null +++ b/scripts/check-release-tag.mjs @@ -0,0 +1,111 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const stableTagPattern = /^(app|core)-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + +const releaseTargets = { + app: { + workspace: 'packages/app', + changelog: 'packages/app/CHANGELOG.md', + }, + core: { + workspace: 'packages/validator-core', + changelog: 'packages/validator-core/CHANGELOG.md', + }, +}; + +export function parseReleaseTag(tag) { + if (typeof tag !== 'string' || tag.length === 0) { + throw new Error('Set RELEASE_TAG or pass an app-vX.Y.Z or core-vX.Y.Z tag.'); + } + + const match = stableTagPattern.exec(tag); + if (match === null) { + throw new Error( + `Invalid release tag "${tag}". Use app-vX.Y.Z or core-vX.Y.Z with a stable SemVer version.`, + ); + } + + const target = match[1]; + if (target !== 'app' && target !== 'core') { + throw new Error(`Unsupported release target "${String(target)}".`); + } + + return { + target, + version: `${match[2]}.${match[3]}.${match[4]}`, + }; +} + +export function validateReleaseMetadata({ tag, packageJson, lockfile, changelog }) { + const release = parseReleaseTag(tag); + const definition = releaseTargets[release.target]; + + if (packageJson.version !== release.version) { + throw new Error( + `${tag} does not match ${definition.workspace}/package.json version ${String(packageJson.version)}.`, + ); + } + + const lockWorkspace = lockfile.packages?.[definition.workspace]; + if (lockWorkspace?.version !== release.version) { + throw new Error( + `${tag} does not match package-lock.json version ${String(lockWorkspace?.version)} for ${definition.workspace}.`, + ); + } + + const escapedVersion = release.version.replaceAll('.', '\\.'); + const changelogPattern = new RegExp( + `^## ${escapedVersion} - \\d{4}-\\d{2}-\\d{2}$`, + 'gmu', + ); + const changelogHeadings = changelog.match(changelogPattern) ?? []; + + if (changelogHeadings.length !== 1) { + throw new Error( + `${definition.changelog} must contain exactly one dated "## ${release.version} - YYYY-MM-DD" heading.`, + ); + } + + return { + ...release, + workspace: definition.workspace, + changelog: definition.changelog, + }; +} + +export async function checkReleaseTag(tag, root = repositoryRoot) { + const release = parseReleaseTag(tag); + const definition = releaseTargets[release.target]; + const [packageText, lockText, changelog] = await Promise.all([ + readFile(resolve(root, definition.workspace, 'package.json'), 'utf8'), + readFile(resolve(root, 'package-lock.json'), 'utf8'), + readFile(resolve(root, definition.changelog), 'utf8'), + ]); + + return validateReleaseMetadata({ + tag, + packageJson: JSON.parse(packageText), + lockfile: JSON.parse(lockText), + changelog, + }); +} + +const invokedAsScript = process.argv[1] !== undefined + && pathToFileURL(resolve(process.argv[1])).href === import.meta.url; + +if (invokedAsScript) { + const tag = process.argv[2] ?? process.env.RELEASE_TAG; + + try { + const release = await checkReleaseTag(tag); + console.log( + `Release tag OK: ${release.target} ${release.version} (${release.workspace}).`, + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/check-release-tag.test.mjs b/scripts/check-release-tag.test.mjs new file mode 100644 index 0000000..bd40978 --- /dev/null +++ b/scripts/check-release-tag.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import test from 'node:test'; + +import { + parseReleaseTag, + validateReleaseMetadata, +} from './check-release-tag.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const scriptPath = resolve(repositoryRoot, 'scripts/check-release-tag.mjs'); + +const validMetadata = { + tag: 'core-v1.2.3', + packageJson: { version: '1.2.3' }, + lockfile: { + packages: { + 'packages/validator-core': { version: '1.2.3' }, + }, + }, + changelog: '# Changelog\n\n## 1.2.3 - 2026-08-12\n', +}; + +test('parses stable app and core tags', () => { + assert.deepEqual(parseReleaseTag('app-v0.2.1'), { + target: 'app', + version: '0.2.1', + }); + assert.deepEqual(parseReleaseTag('core-v10.20.30'), { + target: 'core', + version: '10.20.30', + }); +}); + +test('rejects unknown, prerelease, and leading-zero tags', () => { + for (const tag of ['v1.2.3', 'web-v1.2.3', 'core-v1.2.3-beta.1', 'app-v01.2.3']) { + assert.throws(() => parseReleaseTag(tag), /Invalid release tag/u); + } +}); + +test('accepts matching package, lockfile, and changelog metadata', () => { + assert.deepEqual(validateReleaseMetadata(validMetadata), { + target: 'core', + version: '1.2.3', + workspace: 'packages/validator-core', + changelog: 'packages/validator-core/CHANGELOG.md', + }); +}); + +test('rejects package and lockfile version mismatches', () => { + assert.throws( + () => validateReleaseMetadata({ + ...validMetadata, + packageJson: { version: '1.2.4' }, + }), + /package\.json version 1\.2\.4/u, + ); + assert.throws( + () => validateReleaseMetadata({ + ...validMetadata, + lockfile: { + packages: { + 'packages/validator-core': { version: '1.2.4' }, + }, + }, + }), + /package-lock\.json version 1\.2\.4/u, + ); +}); + +test('requires one dated changelog heading', () => { + assert.throws( + () => validateReleaseMetadata({ + ...validMetadata, + changelog: '# Changelog\n', + }), + /exactly one dated/u, + ); + assert.throws( + () => validateReleaseMetadata({ + ...validMetadata, + changelog: [validMetadata.changelog, validMetadata.changelog].join('\n'), + }), + /exactly one dated/u, + ); +}); + +test('checks current metadata independently of the working directory', async () => { + const appPackage = JSON.parse( + await readFile(resolve(repositoryRoot, 'packages/app/package.json'), 'utf8'), + ); + const result = spawnSync( + process.execPath, + [scriptPath, `app-v${String(appPackage.version)}`], + { + cwd: dirname(repositoryRoot), + encoding: 'utf8', + env: { + ...process.env, + RELEASE_TAG: 'core-v9.9.9', + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Release tag OK: app/u); +}); diff --git a/scripts/toolchain-smoke.mjs b/scripts/toolchain-smoke.mjs index a4430e4..0d45a13 100644 --- a/scripts/toolchain-smoke.mjs +++ b/scripts/toolchain-smoke.mjs @@ -6,6 +6,16 @@ const require = createRequire(import.meta.url); const nativePackage = require('@typescript/native/package.json'); const apiPackage = require('typescript/package.json'); +assertMinimumVersion('Node.js', process.versions.node, [24, 0, 0]); + +const npmVersion = /^npm\/(\d+\.\d+\.\d+)/u.exec( + process.env.npm_config_user_agent ?? '', +)?.[1]; +if (npmVersion === undefined) { + throw new Error('Could not read the npm version from npm_config_user_agent.'); +} +assertMinimumVersion('npm', npmVersion, [11, 5, 1]); + assertMajorVersion('@typescript/native', nativePackage.version, 7); assertMajorVersion('typescript API', apiPackage.version, 6); @@ -29,10 +39,37 @@ if (!/^Version 7\./u.test(compilerVersion)) { throw new Error(`Expected tsc 7.x, received "${compilerVersion}".`); } -console.log(`Toolchain OK: ${compilerVersion}; TypeScript API ${apiPackage.version}.`); +console.log( + `Toolchain OK: Node.js ${process.versions.node}; npm ${npmVersion}; ` + + `${compilerVersion}; TypeScript API ${apiPackage.version}.`, +); function assertMajorVersion(label, version, expectedMajor) { if (typeof version !== 'string' || !version.startsWith(`${expectedMajor}.`)) { throw new Error(`Expected ${label} ${expectedMajor}.x, received "${String(version)}".`); } } + +function assertMinimumVersion(label, version, minimum) { + const parts = version.split('.').map((part) => Number.parseInt(part, 10)); + const valid = minimum.every((_, index) => { + const part = parts[index]; + return part !== undefined && !Number.isNaN(part); + }); + + if (!valid || compareVersions(parts, minimum) < 0) { + throw new Error( + `Expected ${label} ${minimum.join('.')} or newer, received "${version}".`, + ); + } +} + +function compareVersions(left, right) { + for (let index = 0; index < right.length; index += 1) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +}