Skip to content

Commit 43daa0b

Browse files
committed
new GHA does basic validation for component version PR labels
1 parent f8f46a6 commit 43daa0b

7 files changed

Lines changed: 475 additions & 13 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: Validate PR Version Labels
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
types:
7+
- opened
8+
- synchronize
9+
- edited
10+
- labeled # important to catch version label additions
11+
- unlabeled
12+
- reopened
13+
14+
permissions:
15+
contents: read
16+
pull-requests: read
17+
18+
jobs:
19+
validate-version-labels:
20+
name: Validate PR Version Labels
21+
runs-on: ubuntu-latest
22+
23+
steps:
24+
- name: Checkout repo
25+
uses: actions/checkout@v4
26+
27+
- name: Get all labels from PR
28+
id: get_labels
29+
run: |
30+
echo "## PR Labels" >> "$GITHUB_STEP_SUMMARY"
31+
32+
## Newline separated list of all labels on the PR
33+
all_labels=$(jq -r '.pull_request.labels[].name' < "$GITHUB_EVENT_PATH" 2>/dev/null || true)
34+
35+
if [ -z "$all_labels" ]; then
36+
echo "- (none)" >> "$GITHUB_STEP_SUMMARY"
37+
echo "::error title=No Labels::No Labels were found on this PR. A version label is required."
38+
exit 1
39+
else
40+
while IFS= read -r lbl; do
41+
[ -z "$lbl" ] && continue
42+
echo "- \`$lbl\`" >> "$GITHUB_STEP_SUMMARY"
43+
done <<< "$all_labels"
44+
fi
45+
46+
echo "$all_labels" > all_labels.txt
47+
48+
- name: Set up Node.js
49+
uses: actions/setup-node@v4
50+
with:
51+
node-version-file: .nvmrc
52+
53+
- name: Label Validation
54+
id: validate
55+
run: node scripts/internal-ci/validate-version-labels.js all_labels.txt
56+
57+
- name: Validation Summary
58+
if: ${{ always() }}
59+
env:
60+
NO_LABELS: ${{ steps.get_labels.outcome == 'failure' }}
61+
IS_VALID: ${{ steps.validate.outputs.isValid }}
62+
VALIDATION_MESSAGE: ${{ steps.validate.outputs.validationMessage }}
63+
INVALID_VERSION_LABELS: ${{ steps.validate.outputs.invalidVersionLabels }}
64+
HAS_UNTRACKED_VERSION: ${{ steps.validate.outputs.hasUntrackedVersion }}
65+
COMPONENT_VERSION_LABELS: ${{ steps.validate.outputs.componentVersionLabels }}
66+
run: |
67+
echo "## Validation Outcome" >> "$GITHUB_STEP_SUMMARY"
68+
69+
if [ "${{ env.NO_LABELS }}" === "true" ]; then
70+
echo "❌ No labels found on the PR. Add at least one version label." >> "$GITHUB_STEP_SUMMARY"
71+
fi
72+
73+
if [ "${{ env.IS_VALID }}" === "false" ]; then
74+
echo "❌ Version label validation failed." >> "$GITHUB_STEP_SUMMARY"
75+
if [ -n "${{ env.VALIDATION_MESSAGE }}" ]; then
76+
echo "${{ env.VALIDATION_MESSAGE }}" >> "$GITHUB_STEP_SUMMARY"
77+
fi
78+
if [ -n "${{ env.INVALID_VERSION_LABELS }}" ]; then
79+
echo "Invalid version labels: ${{ env.INVALID_VERSION_LABELS }}" >> "$GITHUB_STEP_SUMMARY"
80+
fi
81+
else
82+
echo "✅ Version label validation passed." >> "$GITHUB_STEP_SUMMARY"
83+
fi
84+
echo "Untraced Version: $HAS_UNTRACKED_VERSION" >> "$GITHUB_STEP_SUMMARY"
85+
if [ -n "$COMPONENT_VERSIONS" ]; then
86+
echo "### Component Versions" >> "$GITHUB_STEP_SUMMARY"
87+
echo "$COMPONENT_VERSION_LABELS" >> "$GITHUB_STEP_SUMMARY"
88+
fi
89+
90+
- name: Tags
91+
run: |
92+
echo "## Tags" >> "$GITHUB_STEP_SUMMARY"
93+
echo "TODO: Output expected tag generation results here" >> "$GITHUB_STEP_SUMMARY"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Internal CI
2+
3+
on:
4+
push:
5+
branches-ignore:
6+
- main
7+
8+
permissions:
9+
contents: read
10+
pull-requests: write
11+
checks: write # needed if reporter is github-pr-check or github-check
12+
13+
jobs:
14+
internal-ci:
15+
name: Internal CI
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- name: Checkout repo
20+
uses: actions/checkout@v4
21+
22+
- name: Set up Node.js
23+
uses: actions/setup-node@v4
24+
with:
25+
node-version-file: .nvmrc
26+
27+
- name: Install dependencies
28+
run: npm ci
29+
30+
- name: Dependency Audit
31+
run: npm audit
32+
33+
- name: Test
34+
run: npm test
35+
36+
- name: Lint Check
37+
run: npm run lint:check
38+
39+
- name: Format Check
40+
run: npm run format:check
41+
42+
semgrep:
43+
uses: ./.github/workflows/run_semgrep_scan.yml
44+
secrets: inherit
45+
with:
46+
commit_identifier: $${{ github.sha }}
47+
cancel_in_progress: true
48+
semgrep_config: 'p/default'
49+
fail_severity: 'error'
50+
scan_mode: 'diff'
51+
pr_filter_mode: 'added'
52+
pr_reporter: 'github-pr-review'

.husky/pre-commit

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1 @@
1-
npm audit
2-
npm test
3-
4-
npm run lint:fix
5-
npm run format:fix
6-
7-
npm run lint:check
8-
npm run format:check
9-
10-
npm run scan
1+
npm run ci

eslint.config.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,12 @@ export default defineConfig([
2424
language: 'json/json5',
2525
extends: ['json/recommended'],
2626
},
27+
{
28+
files: ['**/*.test.js'],
29+
languageOptions: {
30+
globals: {
31+
...globals.jest,
32+
},
33+
},
34+
},
2735
]);

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@
2727
"audit": "npm audit --audit-level=high --omit=dev",
2828
"lint:check": "eslint *.js --ext .js,.json",
2929
"lint:fix": "eslint *.js --ext .js,.json --fix",
30-
"format:check": "prettier --check './*.js' './*.mjs' './*.json' './*.md' '.github/actions/**/*.*'",
31-
"format:fix": "prettier --write './*.js' './*.mjs' './*.json' './*.md' '.github/actions/**/*.*'",
32-
"scan": "semgrep --config=p/ci --config=p/security-audit --config=p/javascript ./*.js ./*.mjs ./*.json .github/actions/"
30+
"format:check": "prettier --check './*.js' './*.mjs' './*.json' './*.md' 'scripts/**/*.js' '.github/actions/**/*.*'",
31+
"format:fix": "prettier --write './*.js' './*.mjs' './*.json' './*.md' 'scripts/**/*.js' '.github/actions/**/*.*'",
32+
"scan": "semgrep --config=p/ci --config=p/security-audit --config=p/javascript ./*.js ./*.mjs ./*.json scripts/ .github/actions/",
33+
"ci": "npm run audit && npm run test && npm run lint:check && npm run format:check && npm run scan"
3334
}
3435
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// validate-version-labels.js
2+
// Script to validate version labels for CI workflows
3+
//
4+
// Usage:
5+
// node validate-version-labels.js <labels-file>
6+
// echo -e "version:type/foo:v1.2.3\nversion:type/bar:v2.0.0\nother" | node scripts/internal-ci/validate-version-labels.js
7+
//
8+
// Inputs:
9+
// - Newline-separated list of label strings (from file or stdin)
10+
//
11+
// Outputs:
12+
// - Writes to $GITHUB_OUTPUT variable:
13+
// isValid: true/false
14+
// validationMessage: detailed message of validation errors
15+
// invalidVersionLabels: comma-separated list of invalid version labels
16+
// hasUntrackedVersion: true/false if untracked version label is present
17+
// componentVersionLabels: comma-separated list of valid component version labels
18+
// - Exits with code 1 on validation failure, 0 on success
19+
20+
const fs = require('fs');
21+
22+
// Returns an array of normalized, trimmed, non-empty label strings
23+
function getLabelArray(rawLabels) {
24+
return rawLabels
25+
.split('\n')
26+
.map(l => l.trim().toLowerCase())
27+
.filter(Boolean);
28+
}
29+
30+
const versionLabelPrefix = 'version:';
31+
const untrackedLabel = `${versionLabelPrefix}untracked`; // Special label for untracked versions, 'version:untracked'
32+
const semverPattern = 'v\\d+\\.\\d+\\.\\d+'; // Semantic versioning pattern vX.Y.Z, e.g., v1.2.3
33+
// Pattern for valid version label: version:component-type/component-name:vX.Y.Z where type and name correspond to the path of the component under the ./github directory
34+
const componentVersionRegEx = new RegExp(
35+
`^${versionLabelPrefix}[a-z0-9_-]+\/[a-z0-9_-]+:${semverPattern}$`
36+
);
37+
38+
function getVersionLabelArray(labels) {
39+
return labels.filter(label => label.startsWith(versionLabelPrefix));
40+
}
41+
42+
function getInvalidVersionLabels(versionLabels) {
43+
return versionLabels.filter(
44+
label => label !== untrackedLabel && !componentVersionRegEx.test(label)
45+
);
46+
}
47+
48+
function getComponentVersionLabels(versionLabels) {
49+
return versionLabels.filter(label => componentVersionRegEx.test(label));
50+
}
51+
52+
function basicValidate(invalidVersionLabels, componentVersionLabels, hasUntrackedVersion) {
53+
const hasVersionLabels =
54+
invalidVersionLabels.length + componentVersionLabels.length + (hasUntrackedVersion ? 1 : 0) > 0;
55+
const hasInvalidVersionLabels = invalidVersionLabels.length > 0;
56+
const hasConflictingVersions = hasUntrackedVersion && componentVersionLabels.length > 0;
57+
58+
let basicValidationMessage = '';
59+
if (!hasVersionLabels) {
60+
basicValidationMessage = '- No version labels found. At least one version label is required.';
61+
}
62+
if (hasInvalidVersionLabels) {
63+
basicValidationMessage += '- Invalid version labels found.\n';
64+
}
65+
if (hasConflictingVersions) {
66+
basicValidationMessage +=
67+
'- Conflicting version labels found: both untracked and component version labels present.\n';
68+
}
69+
70+
return {
71+
isValid: hasVersionLabels && !hasInvalidVersionLabels && !hasConflictingVersions,
72+
basicValidationMessage: basicValidationMessage.trim(),
73+
};
74+
}
75+
76+
// Main entry
77+
if (require.main === module) {
78+
// Accept labels from stdin or as a file argument
79+
let rawLabels = '';
80+
if (process.argv.length > 2) {
81+
rawLabels = fs.readFileSync(process.argv[2], 'utf8');
82+
} else {
83+
rawLabels = fs.readFileSync(0, 'utf8'); // Read from stdin
84+
}
85+
86+
const labels = getLabelArray(rawLabels);
87+
console.log('Normalized Labels:', labels);
88+
89+
const versionLabels = getVersionLabelArray(labels);
90+
91+
const invalidVersionLabels = getInvalidVersionLabels(versionLabels);
92+
const componentVersionLabels = getComponentVersionLabels(versionLabels);
93+
const hasUntrackedVersion = versionLabels.includes(untrackedLabel);
94+
95+
// Validate
96+
let { isValid, basicValidationMessage } = basicValidate(
97+
invalidVersionLabels,
98+
componentVersionLabels,
99+
hasUntrackedVersion
100+
);
101+
102+
const validationMessage = (
103+
basicValidationMessage ?? '✅ Version labels validation passed.'
104+
).replace(/\n/g, '\\n');
105+
106+
// write outputs for use in later steps
107+
const githubOutput = process.env.GITHUB_OUTPUT;
108+
if (githubOutput) {
109+
fs.appendFileSync(githubOutput, `isValid=${isValid}\n`);
110+
fs.appendFileSync(githubOutput, `validationMessage=${validationMessage}\n`);
111+
fs.appendFileSync(githubOutput, `invalidVersionLabels=${invalidVersionLabels.join(',')}\n`);
112+
fs.appendFileSync(githubOutput, `hasUntrackedVersion=${hasUntrackedVersion}\n`);
113+
fs.appendFileSync(githubOutput, `componentVersionLabels=${componentVersionLabels.join(',')}\n`);
114+
}
115+
116+
// Set exit code based on validation
117+
if (!isValid) {
118+
console.error('::error title=Version Label Validation::' + validationMessage);
119+
process.exit(1);
120+
}
121+
122+
console.log(validationMessage);
123+
process.exit(0);
124+
}
125+
126+
module.exports = {
127+
getLabelArray,
128+
getVersionLabelArray,
129+
getInvalidVersionLabels,
130+
getComponentVersionLabels,
131+
basicValidate,
132+
versionLabelPrefix,
133+
untrackedLabel,
134+
componentVersionRegEx,
135+
};

0 commit comments

Comments
 (0)