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
7 changes: 3 additions & 4 deletions .github/actions/check-scoped-commits/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ runs:
TAG_PREFIX="${{ inputs.tag-prefix }}"
SCOPES="${{ inputs.scopes }}"

SCOPE_PATTERN=$(echo "$SCOPES" | tr ',' '|')
REGEX="^\w+\((${SCOPE_PATTERN})\)"

LAST_TAG=$(git tag -l "${TAG_PREFIX}*" --sort=-creatordate | head -1)

if [ -z "$LAST_TAG" ]; then
Expand All @@ -48,7 +45,9 @@ runs:
COMMITS=$(git log --oneline --format='%s' "${LAST_TAG}..HEAD")
fi

RELEVANT=$(echo "$COMMITS" | grep -E "$REGEX" || true)
RELEVANT=$(printf '%s\n' "$COMMITS" |
node "$GITHUB_WORKSPACE/.github/scripts/scope-contract.cjs" \
filter-commits "$SCOPES")

if [ -z "$RELEVANT" ]; then
echo "has-changes=false" >> "$GITHUB_OUTPUT"
Expand Down
127 changes: 127 additions & 0 deletions .github/scope-contract.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
{
"schemaVersion": 1,
"scopes": [
"analyzer",
"autobuilder",
"ci",
"cli",
"core",
"docs",
"github",
"gitlab",
"infra",
"model",
"rules"
],
"compatibility": {
"forbiddenPairs": [
["cli", "analyzer"],
["cli", "autobuilder"],
["cli", "core"],
["cli", "model"],
["cli", "rules"]
],
"exclusiveGroups": [
["github", "gitlab", "infra"]
],
"companions": {
"github": ["docs", "ci"],
"gitlab": ["docs", "ci"],
"infra": ["docs", "ci"]
}
},
"ownership": {
"ignoredRoots": [
"formal"
],
"overrides": [
{
"scope": "ci",
"globs": [
"release-notes-transform.cjs",
"**/.releaserc.cjs"
]
}
],
"roots": {
"rules": {
"defaultScope": "rules"
},
"model": {
"defaultScope": "model"
},
"github": {
"defaultScope": "github"
},
"gitlab": {
"defaultScope": "gitlab"
},
"infra": {
"defaultScope": "infra"
},
"cli": {
"defaultScope": "cli"
},
"core": {
"defaultScope": "core",
"rules": [
{
"scope": "autobuilder",
"globs": [
"opentaint-jvm-autobuilder/**",
"opentaint-project-model/**",
"opentaint-utils/cli-util/**"
]
},
{
"scope": "analyzer",
"globs": [
"opentaint-ir/**/src/test/**",
"src/**",
"samples/**",
"opentaint-jvm-sast-dataflow/**",
"opentaint-jvm-sast-project/**",
"opentaint-jvm-sast-se-api/**",
"opentaint-go-querylang/**",
"opentaint-java-querylang/**",
"opentaint-config/**",
"opentaint-configuration-rules/**",
"opentaint-utils/common-util/**",
"opentaint-utils/opentaint-jvm-util/**"
]
}
]
},
".github": {
"defaultScope": "ci"
}
},
"documentation": {
"scope": "docs",
"rootDirectories": [
"public",
"docs",
"skills",
"skills-templates",
"scripts",
"logos",
".claude-plugin",
".codex-plugin"
],
"basenames": [
"README.md",
"CHANGELOG.md",
"LICENSE",
"LICENSE.md"
],
"extensions": [
".svg",
".png"
],
"rootFiles": [
".gitignore",
"Makefile"
]
}
}
}
63 changes: 63 additions & 0 deletions .github/scripts/release-scope-semantic.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { analyzeCommits } from '@semantic-release/commit-analyzer';

const require = createRequire(import.meta.url);
const {
RELEASE_PARSER_OPTIONS,
createReleaseRules,
} = require('./scope-contract.cjs');

const logger = { log() {} };

async function releaseFor(scope, message) {
return analyzeCommits(
{
preset: 'conventionalcommits',
parserOpts: RELEASE_PARSER_OPTIONS,
releaseRules: createReleaseRules(scope),
},
{
commits: [{ message }],
logger,
},
);
}

test('real analyzer matches each scope token position', async () => {
assert.equal(
await releaseFor('model', 'fix(model,analyzer,rules): Update models'),
'patch',
);
assert.equal(
await releaseFor('model', 'fix(analyzer,model,rules): Update models'),
'patch',
);
assert.equal(
await releaseFor('model', 'fix(analyzer,rules,model): Update models'),
'patch',
);
});

test('real analyzer keeps release types', async () => {
assert.equal(
await releaseFor('rules', 'feat(model,analyzer,rules): Update models'),
'minor',
);
assert.equal(
await releaseFor('rules', 'fix(model,analyzer,rules): Update models'),
'patch',
);
});

test('real analyzer does not release an absent scope', async () => {
assert.equal(
await releaseFor('cli', 'fix(model,analyzer,rules): Update models'),
null,
);
});

test('real analyzer keeps single-scope compatibility', async () => {
assert.equal(await releaseFor('rules', 'fix(rules): Update rules'), 'patch');
});
92 changes: 92 additions & 0 deletions .github/scripts/release-scope.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');

const {
RELEASE_PARSER_OPTIONS,
createReleaseRules,
hasAnyScope,
scopeExclusionPattern,
scopePatterns,
splitScopes,
} = require('./scope-contract.cjs');
const { createTransform } = require('../../release-notes-transform.cjs');

test('splitScopes keeps ordered scope tokens', () => {
assert.deepEqual(
splitScopes('model,analyzer,rules'),
['model', 'analyzer', 'rules'],
);
});

test('release parser accepts a comma-separated scope list', () => {
const parsed = RELEASE_PARSER_OPTIONS.headerPattern.exec(
'fix(model,analyzer,rules): Update models',
);
assert.notEqual(parsed, null);
assert.deepEqual(parsed.slice(1), [
'fix',
'model,analyzer,rules',
'Update models',
]);
});

test('release matching uses scope intersection', () => {
assert.equal(
hasAnyScope('model,analyzer,rules', ['analyzer', 'core', 'model']),
true,
);
assert.equal(hasAnyScope('model,analyzer,rules', ['rules']), true);
assert.equal(hasAnyScope('model,analyzer,rules', ['cli']), false);
});

test('scope patterns match each token position', () => {
assert.deepEqual(
scopePatterns('rules'),
['rules', 'rules,*', '*,rules', '*,rules,*'],
);
});

test('release rules include every token position and an exact exclusion', () => {
const rules = createReleaseRules('rules');
assert.equal(
rules.some(rule =>
rule.scope === '*,rules,*' && rule.type === 'feat' && rule.release === 'minor'
),
true,
);
assert.equal(
rules.some(rule =>
rule.scope === scopeExclusionPattern('rules') && rule.release === false
),
true,
);
});

function commit(scope) {
return {
scope,
type: 'fix',
notes: [],
hash: '1234567890',
subject: 'Update release matching',
references: [],
};
}

test('release notes include a commit with an allowed scope token', () => {
const transformed = createTransform(['rules'])(
commit('model,analyzer,rules'),
{},
);
assert.notEqual(transformed, null);
});

test('release notes exclude a commit without an allowed scope token', () => {
const transformed = createTransform(['cli'])(
commit('model,analyzer,rules'),
{},
);
assert.equal(transformed, null);
});
Loading
Loading