Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions .github/workflows/publish-core.yml
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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).
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@
"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",
"smoke:toolchain": "node scripts/toolchain-smoke.mjs",
"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",
Expand Down
111 changes: 111 additions & 0 deletions scripts/check-release-tag.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading