diff --git a/.github/workflows/required-quality-gate.yml b/.github/workflows/required-quality-gate.yml index 6d903e7..3f0a9d0 100644 --- a/.github/workflows/required-quality-gate.yml +++ b/.github/workflows/required-quality-gate.yml @@ -13,11 +13,63 @@ concurrency: jobs: quality-gate: - name: quality-gate - uses: HMG-AI/.github/.github/workflows/reusable-commitlint.yml@978d6d08d6bfc1e8a3662c1f9b0daab8bfa0e557 - with: - base_sha: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} - head_sha: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + name: quality-gate / commitlint + runs-on: ubuntu-latest + timeout-minutes: 10 + container: + image: node:24.11.1-bookworm@sha256:9a2ed90cd91b1f3412affe080b62e69b057ba8661d9844e143a6bbd76a23260f + options: --security-opt=no-new-privileges + steps: + - name: Validate commit SHA inputs + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + run: | + set -Eeuo pipefail + readonly sha_pattern='^[0-9a-f]{40}$' + + for sha_name in BASE_SHA HEAD_SHA; do + sha_value="${!sha_name}" + if [[ ! "${sha_value}" =~ ${sha_pattern} ]]; then + echo "::error::${sha_name} must be a full 40-character lowercase hexadecimal commit SHA" + exit 2 + fi + done + + - name: Check out caller repository history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + path: caller + persist-credentials: false + + - name: Check out immutable organization commit policy + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: HMG-AI/.github + ref: ${{ github.workflow_sha }} + path: governance-source + sparse-checkout: governance/commitlint + persist-credentials: false + + - name: Install locked commitlint dependencies + shell: bash + working-directory: governance-source/governance/commitlint + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Lint every commit in base..head + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + run: | + set -Eeuo pipefail + bash "${GITHUB_WORKSPACE}/governance-source/governance/commitlint/lint-range.sh" \ + "${GITHUB_WORKSPACE}/caller" \ + "${BASE_SHA}" \ + "${HEAD_SHA}" workflow-security-policy: name: workflow-security-policy diff --git a/governance/commitlint/commitlint.config.mjs b/governance/commitlint/commitlint.config.mjs index 78470f5..e22e22a 100644 --- a/governance/commitlint/commitlint.config.mjs +++ b/governance/commitlint/commitlint.config.mjs @@ -7,6 +7,26 @@ const noHan = (field) => (parsed) => [ `${field} must not contain Unicode Han script characters`, ]; +const canonicalLongProvenanceTrailers = [ + /^HMG-Provenance-Key-ID: ed25519-spki-sha256-[0-9a-f]{64}$/, + /^HMG-Provenance-Signature-Ed25519: [A-Za-z0-9+/]{86}==$/, +]; + +const boundedFooterLines = (parsed, _when, limit = 100) => { + const invalidLines = (parsed.footer ?? '') + .split('\n') + .filter( + (line) => + line.length > limit && + !canonicalLongProvenanceTrailers.some((pattern) => pattern.test(line)), + ); + + return [ + invalidLines.length === 0, + `footer lines must not exceed ${limit} characters unless they are canonical HMG provenance trailers`, + ]; +}; + export default { ...conventional, plugins: [ @@ -15,11 +35,17 @@ export default { 'subject-no-han': noHan('subject'), 'body-no-han': noHan('body'), 'footer-no-han': noHan('footer'), + 'footer-bounded-lines': boundedFooterLines, }, }, ], rules: { ...conventional.rules, + // The two canonical cryptographic trailers exceed the conventional + // 100-character limit. Disable the broad built-in rule and replace it + // with a strict shape-aware exception. + 'footer-max-line-length': [0], + 'footer-bounded-lines': [2, 'always', 100], 'subject-no-han': [2, 'always'], 'body-no-han': [2, 'always'], 'footer-no-han': [2, 'always'], diff --git a/governance/commitlint/test/commitlint.test.mjs b/governance/commitlint/test/commitlint.test.mjs index 09e894c..46ebe0a 100644 --- a/governance/commitlint/test/commitlint.test.mjs +++ b/governance/commitlint/test/commitlint.test.mjs @@ -39,6 +39,32 @@ test('accepts a conventional English commit message', () => { assert.equal(result.status, 0, result.stdout || result.stderr); }); +test('accepts canonical cryptographic provenance trailers', () => { + const message = [ + 'chore(public): promote HMG v1.7.7', + '', + `HMG-Provenance-Key-ID: ed25519-spki-sha256-${'a'.repeat(64)}`, + `HMG-Provenance-Signature-Ed25519: ${'A'.repeat(86)}==`, + '', + ].join('\n'); + const result = lint(message); + assert.equal(result.status, 0, result.stdout || result.stderr); +}); + +test('rejects an ordinary footer line longer than 100 characters', () => { + assertLintFailure( + `chore: reject an oversized footer\n\nAudit-Value: ${'a'.repeat(130)}\n`, + 'footer-bounded-lines', + ); +}); + +test('rejects a long provenance-shaped trailer with a malformed value', () => { + assertLintFailure( + `chore(public): reject malformed provenance\n\nHMG-Provenance-Signature-Ed25519: ${'A'.repeat(85)}===\n`, + 'footer-bounded-lines', + ); +}); + test('rejects Unicode Han script in subject, body, and footer', () => { assertLintFailure('fix: 修复 tenant lookup\n', 'subject-no-han'); assertLintFailure(