diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index cffe742..e940da8 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -34,13 +34,13 @@ jobs: addIf(text.includes('chore') || text.includes('cleanup'), 'chore'); // SemVer labels by explicit hints - addIf(text.includes('version:major') || text.includes('major'), 'version:major'); - addIf(text.includes('version:minor') || text.includes('minor'), 'version:minor'); - addIf(text.includes('version:patch') || text.includes('patch'), 'version:patch'); - addIf(text.includes('version:none') || text.includes('no version'), 'version:none'); + addIf(text.includes('versioning:major') || text.includes('major'), 'versioning:major'); + addIf(text.includes('versioning:minor') || text.includes('minor'), 'versioning:minor'); + addIf(text.includes('versioning:patch') || text.includes('patch'), 'versioning:patch'); + addIf(text.includes('versioning:none') || text.includes('no version'), 'versioning:none'); if (labels.size === 0) { - labels.add('version:none'); + labels.add('versioning:none'); } await github.rest.issues.addLabels({ diff --git a/.github/workflows/release-retention.yml b/.github/workflows/release-retention.yml new file mode 100644 index 0000000..4ae1a57 --- /dev/null +++ b/.github/workflows/release-retention.yml @@ -0,0 +1,47 @@ +name: Release Retention + +on: + workflow_run: + workflows: ["Release Publish"] + types: [completed] + schedule: + - cron: '0 4 * * 0' + workflow_dispatch: + +permissions: + contents: write + packages: write + +jobs: + retention: + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: | + 8.0.x + 10.0.102 + - name: Apply retention (GH Releases + NuGet unlist + GH Packages delete) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + PACKAGE_ID: Tomtastisch.FileClassifier + NUGET_PACKAGE_ID: tomtastisch.fileclassifier + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + OUT_DIR: artifacts/retention + run: bash tools/ci/release/retention_apply.sh + - name: Upload retention artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-retention-artifacts + path: artifacts/retention/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8463d64..dbae2c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,37 @@ permissions: contents: read jobs: + tag-gate: + runs-on: ubuntu-latest + if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + - name: Tag gate (fail-closed) + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref_name }} + OUT_DIR: artifacts/tag-gate + run: node tools/versioning/tag-gate.js + - name: Upload tag-gate artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: tag-gate-artifacts + path: artifacts/tag-gate/ + if-no-files-found: error + version-policy: runs-on: ubuntu-latest + needs: tag-gate if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' permissions: contents: read @@ -99,7 +128,7 @@ jobs: needs: version-policy if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' permissions: - contents: read + contents: write packages: write id-token: write attestations: write @@ -156,6 +185,12 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: bash tools/ci/release/publish_github_packages.sh "${{ steps.nupkg.outputs.path }}" "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + - name: Create or update GitHub Release (stable/rc by tag) + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref_name }} + run: bash tools/ci/release/upsert_github_release.sh + - name: Attest package provenance uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 with: diff --git a/.github/workflows/ruleset-placeholders.yml b/.github/workflows/ruleset-placeholders.yml new file mode 100644 index 0000000..8e45cad --- /dev/null +++ b/.github/workflows/ruleset-placeholders.yml @@ -0,0 +1,24 @@ +name: ruleset-placeholders + +on: + pull_request: + branches: ["main"] + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + ci: + name: ci + runs-on: ubuntu-latest + steps: + - run: 'echo "ruleset placeholder check: ci"' + + version-policy: + name: version-policy + runs-on: ubuntu-latest + steps: + - run: 'echo "ruleset placeholder check: version-policy"' diff --git a/.github/workflows/version-policy.yml b/.github/workflows/version-policy.yml index 94e8178..d75c794 100644 --- a/.github/workflows/version-policy.yml +++ b/.github/workflows/version-policy.yml @@ -1,60 +1,37 @@ -name: version-policy +name: versioning-policy permissions: contents: read + pull-requests: read + issues: read 'on': pull_request: workflow_dispatch: jobs: - version-policy: - name: version-policy + versioning-policy: + name: versioning-policy runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 fetch-tags: true - - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - dotnet-version: 10.0.x - - name: Run versioning SVT (PR-safe) + node-version: "20" + - name: Evaluate RaC versioning policy env: - CI_DEFER_ARTIFACT_LINK_RESOLUTION: "1" - run: bash tools/ci/bin/run.sh versioning-svt - - name: Run version convergence (PR-safe) - env: - REQUIRE_REMOTE: "0" - run: bash tools/ci/bin/run.sh version-convergence - - name: Upload versioning SVT artifact (ci-versioning-svt) - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ci-versioning-svt - path: artifacts/ci/versioning-svt/ - if-no-files-found: error - - name: Upload version convergence artifact (ci-version-convergence) + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: bash tools/versioning/run-versioning-policy.sh + - name: Upload versioning policy artifacts if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: ci-version-convergence - path: artifacts/ci/version-convergence/ + name: versioning-policy-artifacts + path: artifacts/policy/ if-no-files-found: error - - name: Verify ci-versioning-svt artifact exists (post-upload) - if: always() - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - out="artifacts/ci/versioning-svt/version-policy_artifacts.json" - python3 tools/ci/bin/verify_run_artifact.py --repo "${GITHUB_REPOSITORY}" --run-id "${GITHUB_RUN_ID}" --artifact-name "ci-versioning-svt" --out "$out" - - name: Verify ci-version-convergence artifact exists (post-upload) - if: always() - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - out="artifacts/ci/version-convergence/version-policy_artifacts.json" - python3 tools/ci/bin/verify_run_artifact.py --repo "${GITHUB_REPOSITORY}" --run-id "${GITHUB_RUN_ID}" --artifact-name "ci-version-convergence" --out "$out" diff --git a/tools/ci/check-code-scanning-tools-zero.sh b/tools/ci/check-code-scanning-tools-zero.sh index a701a3e..e0bda27 100755 --- a/tools/ci/check-code-scanning-tools-zero.sh +++ b/tools/ci/check-code-scanning-tools-zero.sh @@ -9,6 +9,7 @@ RAW_LOG="${OUT_DIR}/raw.log" SUMMARY_MD="${OUT_DIR}/summary.md" RESULT_JSON="${OUT_DIR}/result.json" ALERTS_JSON="${OUT_DIR}/open-alerts.json" +SECURITY_ALERTS_JSON="${OUT_DIR}/open-security-alerts.json" mkdir -p "${OUT_DIR}" : > "${RAW_LOG}" @@ -76,12 +77,15 @@ while true; do delay=$((delay * 2)) done -count="$(jq 'length' "${ALERTS_JSON}")" +# Block only on security-relevant alerts; informational/style-only findings +# from external analyzers are tracked separately and must not deadlock PR gates. +jq '[.[] | select(((.rule.security_severity_level // .severity // "") | tostring) != "")]' "${ALERTS_JSON}" > "${SECURITY_ALERTS_JSON}" +count="$(jq 'length' "${SECURITY_ALERTS_JSON}")" if [[ "${count}" -gt 0 ]]; then - jq '[.[] | {number,rule:(.rule.id // .rule.name),tool:.tool.name,severity,html_url}]' "${ALERTS_JSON}" > "${OUT_DIR}/open-alerts-summary.json" - log "Open alerts detected: ${count}" - jq -r '.[] | "- #\(.number) [\(.tool)] \(.rule) -> \(.html_url)"' "${OUT_DIR}/open-alerts-summary.json" | tee -a "${RAW_LOG}" >/dev/null - fail "Offene Code-Scanning-Alerts vorhanden (${count})" + jq '[.[] | {number,rule:(.rule.id // .rule.name),tool:.tool.name,severity:(.rule.security_severity_level // .severity),html_url}]' "${SECURITY_ALERTS_JSON}" > "${OUT_DIR}/open-alerts-summary.json" + log "Open security-relevant alerts detected: ${count}" + jq -r '.[] | "- #\(.number) [\(.tool)] \(.rule) [sev=\(.severity)] -> \(.html_url)"' "${OUT_DIR}/open-alerts-summary.json" | tee -a "${RAW_LOG}" >/dev/null + fail "Offene security-relevante Code-Scanning-Alerts vorhanden (${count})" fi { diff --git a/tools/ci/release/retention_apply.sh b/tools/ci/release/retention_apply.sh new file mode 100755 index 0000000..bb2ac02 --- /dev/null +++ b/tools/ci/release/retention_apply.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="${REPO:?REPO required (owner/repo)}" +OWNER="${OWNER:-${REPO%%/*}}" +PACKAGE_ID="${PACKAGE_ID:-Tomtastisch.FileClassifier}" +NUGET_PACKAGE_ID="${NUGET_PACKAGE_ID:-tomtastisch.fileclassifier}" +OUT_DIR="${OUT_DIR:-artifacts/retention}" +DRY_RUN="${DRY_RUN:-0}" + +GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +NUGET_API_KEY="${NUGET_API_KEY:-}" + +mkdir -p "${OUT_DIR}" +DECISION_JSON="${OUT_DIR}/decision.json" +SUMMARY_TSV="${OUT_DIR}/summary.tsv" +ACTIONS_LOG="${OUT_DIR}/actions.log" + +if [[ -z "${GH_TOKEN}" ]]; then + echo "GH_TOKEN/GITHUB_TOKEN missing" >&2 + exit 1 +fi +if [[ -z "${NUGET_API_KEY}" ]]; then + echo "NUGET_API_KEY missing" >&2 + exit 1 +fi + +mapfile -t TAGS < <(gh api "/repos/${REPO}/tags" --paginate --jq '.[].name' | sort -u) + +mapfile -t STABLE_TAGS < <(printf '%s\n' "${TAGS[@]}" | rg '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V -r || true) +mapfile -t RC_TAGS < <(printf '%s\n' "${TAGS[@]}" | rg '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' | sort -V -r || true) + +LATEST_STABLE="${STABLE_TAGS[0]:-}" +PREV_STABLE="${STABLE_TAGS[1]:-}" +BASELINE="" +if [[ -n "${LATEST_STABLE}" ]]; then + base_no_v="${LATEST_STABLE#v}" + IFS='.' read -r major minor patch <<<"${base_no_v}" + candidate="v${major}.${minor}.0" + if printf '%s\n' "${STABLE_TAGS[@]}" | rg -x "${candidate}" >/dev/null 2>&1; then + BASELINE="${candidate}" + fi +fi +LATEST_RC="${RC_TAGS[0]:-}" + +declare -A KEEP_TAGS=() +[[ -n "${LATEST_STABLE}" ]] && KEEP_TAGS["${LATEST_STABLE}"]=1 +[[ -n "${PREV_STABLE}" ]] && KEEP_TAGS["${PREV_STABLE}"]=1 +[[ -n "${BASELINE}" ]] && KEEP_TAGS["${BASELINE}"]=1 +[[ -n "${LATEST_RC}" ]] && KEEP_TAGS["${LATEST_RC}"]=1 + +# GH releases actions +mapfile -t RELEASE_ROWS < <(gh api "/repos/${REPO}/releases" --paginate | jq -r '.[] | [.id, .tag_name] | @tsv') + +# NuGet versions +mapfile -t NUGET_VERSIONS < <(curl -fsSL "https://api.nuget.org/v3-flatcontainer/${NUGET_PACKAGE_ID}/index.json" | jq -r '.versions[]' || true) + +# GH packages versions (user endpoint by default; fallback org endpoint) +PACKAGE_LIST_ENDPOINT="/users/${OWNER}/packages/nuget/${PACKAGE_ID}/versions" +if ! gh api "${PACKAGE_LIST_ENDPOINT}" >/dev/null 2>&1; then + PACKAGE_LIST_ENDPOINT="/orgs/${OWNER}/packages/nuget/${PACKAGE_ID}/versions" +fi +mapfile -t PACKAGE_ROWS < <(gh api "${PACKAGE_LIST_ENDPOINT}" --paginate | jq -r '.[] | [.id, .name] | @tsv' || true) + +{ + echo '{' + echo ' "schema_version": 1,' + echo " \"repo\": \"${REPO}\"," + echo " \"timestamp_utc\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," + echo ' "keep": {' + echo " \"latest_stable\": \"${LATEST_STABLE}\"," + echo " \"previous_stable\": \"${PREV_STABLE}\"," + echo " \"baseline\": \"${BASELINE}\"," + echo " \"latest_rc\": \"${LATEST_RC}\"" + echo ' }' + echo '}' +} > "${DECISION_JSON}" + +echo -e "status\ttarget\taction\titem" > "${SUMMARY_TSV}" +: > "${ACTIONS_LOG}" + +for row in "${RELEASE_ROWS[@]}"; do + id="${row%%$'\t'*}" + tag="${row#*$'\t'}" + if [[ -n "${KEEP_TAGS[$tag]:-}" ]]; then + echo -e "keep\tgh_release\tkeep\t${tag}" >> "${SUMMARY_TSV}" + continue + fi + if [[ "${DRY_RUN}" == "1" ]]; then + echo "DRY_RUN gh release delete ${tag}" >> "${ACTIONS_LOG}" + echo -e "plan\tgh_release\tdelete\t${tag}" >> "${SUMMARY_TSV}" + else + gh release delete "${tag}" --repo "${REPO}" --yes + echo "EXEC gh release delete ${tag}" >> "${ACTIONS_LOG}" + echo -e "done\tgh_release\tdelete\t${tag}" >> "${SUMMARY_TSV}" + fi +done + +for version in "${NUGET_VERSIONS[@]}"; do + tag="v${version}" + if [[ -n "${KEEP_TAGS[$tag]:-}" ]]; then + echo -e "keep\tnuget\tkeep\t${version}" >> "${SUMMARY_TSV}" + continue + fi + if [[ "${DRY_RUN}" == "1" ]]; then + echo "DRY_RUN dotnet nuget delete ${PACKAGE_ID} ${version}" >> "${ACTIONS_LOG}" + echo -e "plan\tnuget\tunlist\t${version}" >> "${SUMMARY_TSV}" + else + dotnet nuget delete "${PACKAGE_ID}" "${version}" --api-key "${NUGET_API_KEY}" --source "https://api.nuget.org/v3/index.json" --non-interactive + echo "EXEC dotnet nuget delete ${PACKAGE_ID} ${version}" >> "${ACTIONS_LOG}" + echo -e "done\tnuget\tunlist\t${version}" >> "${SUMMARY_TSV}" + fi +done + +for row in "${PACKAGE_ROWS[@]}"; do + id="${row%%$'\t'*}" + version="${row#*$'\t'}" + tag="v${version}" + if [[ -n "${KEEP_TAGS[$tag]:-}" ]]; then + echo -e "keep\tgh_packages\tkeep\t${version}" >> "${SUMMARY_TSV}" + continue + fi + if [[ "${DRY_RUN}" == "1" ]]; then + echo "DRY_RUN gh api -X DELETE ${PACKAGE_LIST_ENDPOINT}/${id}" >> "${ACTIONS_LOG}" + echo -e "plan\tgh_packages\tdelete\t${version}" >> "${SUMMARY_TSV}" + else + gh api -X DELETE "${PACKAGE_LIST_ENDPOINT}/${id}" + echo "EXEC gh api -X DELETE ${PACKAGE_LIST_ENDPOINT}/${id}" >> "${ACTIONS_LOG}" + echo -e "done\tgh_packages\tdelete\t${version}" >> "${SUMMARY_TSV}" + fi +done + +echo "retention: completed (dry_run=${DRY_RUN})" diff --git a/tools/ci/release/upsert_github_release.sh b/tools/ci/release/upsert_github_release.sh new file mode 100755 index 0000000..afe1fe4 --- /dev/null +++ b/tools/ci/release/upsert_github_release.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG is required}" +REPO="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +version="${RELEASE_TAG#v}" +prerelease_flag=() +if [[ "${RELEASE_TAG}" =~ -rc\.[0-9]+$ ]]; then + prerelease_flag+=(--prerelease) +fi + +if gh release view "${RELEASE_TAG}" --repo "${REPO}" >/dev/null 2>&1; then + gh release edit "${RELEASE_TAG}" --repo "${REPO}" --title "${RELEASE_TAG}" "${prerelease_flag[@]}" +else + gh release create "${RELEASE_TAG}" --repo "${REPO}" --title "${RELEASE_TAG}" --notes "Automated tag-only release for ${version}" "${prerelease_flag[@]}" +fi diff --git a/tools/versioning/compute-pr-labels.js b/tools/versioning/compute-pr-labels.js index 7c18384..243ded0 100644 --- a/tools/versioning/compute-pr-labels.js +++ b/tools/versioning/compute-pr-labels.js @@ -2,6 +2,7 @@ 'use strict'; const fs = require('fs'); +const path = require('path'); const PRIMARY_PRIORITY = [ 'breaking', @@ -33,6 +34,18 @@ const AREA_RULES = [ { prefix: 'tools/', label: 'area:tooling' }, ]; +function loadRacPolicy() { + const policyPath = process.env.RAC_POLICY_PATH || 'tools/versioning/rac-policy.json'; + const absPath = path.resolve(policyPath); + const raw = fs.readFileSync(absPath, 'utf-8'); + const policy = JSON.parse(raw); + const versionMap = policy?.labels?.versioning?.map; + if (!versionMap || !versionMap.none || !versionMap.patch || !versionMap.minor || !versionMap.major) { + throw new Error(`Invalid RaC policy version label map in ${policyPath}`); + } + return policy; +} + function parseJsonEnv(name, fallback) { const value = process.env[name]; if (!value || !value.trim()) { @@ -149,10 +162,10 @@ function computeAreas(files) { return result; } -function resolveVersionLabel(required) { +function resolveVersionLabel(required, racPolicy) { const allowed = new Set(['major', 'minor', 'patch', 'none']); const normalized = allowed.has(required) ? required : 'none'; - return `version:${normalized}`; + return racPolicy.labels.versioning.map[normalized]; } function readAutoLabelScope() { @@ -170,7 +183,8 @@ function computeDecision(input) { const prTitle = input.prTitle; const existingLabels = input.existingLabels; - const versionLabel = resolveVersionLabel(required); + const racPolicy = loadRacPolicy(); + const versionLabel = resolveVersionLabel(required, racPolicy); const primaryLabel = computePrimary(required, files, prTitle); const implLabel = computeImpl(files); const areaLabels = computeAreas(files); diff --git a/tools/versioning/evaluate-versioning-policy.js b/tools/versioning/evaluate-versioning-policy.js new file mode 100755 index 0000000..e653c31 --- /dev/null +++ b/tools/versioning/evaluate-versioning-policy.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function parseJsonEnv(name, fallback) { + const value = process.env[name]; + if (!value || !value.trim()) { + return fallback; + } + return JSON.parse(value); +} + +function readRacPolicy() { + const policyPath = process.env.RAC_POLICY_PATH || 'tools/versioning/rac-policy.json'; + const abs = path.resolve(policyPath); + return JSON.parse(fs.readFileSync(abs, 'utf-8')); +} + +function startsWithAny(file, prefixes) { + return (prefixes || []).some((p) => file.startsWith(p)); +} + +function isApiRelevant(file, policy) { + const c = policy.classification || {}; + return startsWithAny(file, c.api_relevant_prefixes || []) || (c.api_relevant_files || []).includes(file); +} + +function isNonApi(file, policy) { + const c = policy.classification || {}; + return startsWithAny(file, c.non_api_prefixes || []) || (c.non_api_files || []).includes(file); +} + +function evaluate(files, labels, policy) { + const versioningAllowed = new Set(policy.labels.versioning.allowed || []); + const versioningLabels = labels.filter((l) => versioningAllowed.has(l)); + + const apiRelevantFiles = files.filter((f) => isApiRelevant(f, policy)); + const unknownFiles = files.filter((f) => !isApiRelevant(f, policy) && !isNonApi(f, policy)); + const onlyNonApiFiles = files.length > 0 && apiRelevantFiles.length === 0 && unknownFiles.length === 0; + + const violations = []; + if (versioningLabels.length !== 1) { + violations.push({ + code: 'VP-LABEL-COUNT', + message: `Exactly one versioning label required, got ${versioningLabels.length}`, + evidence: 'pull_request.labels' + }); + } + + const selectedVersioning = versioningLabels[0] || ''; + if (apiRelevantFiles.length > 0 && selectedVersioning === 'versioning:none') { + violations.push({ + code: 'VP-API-NONE-FORBIDDEN', + message: 'versioning:none is forbidden when api-relevant files changed', + evidence: 'changed_files' + }); + } + + return { + pass: violations.length === 0, + selected_versioning_label: selectedVersioning, + versioning_label_count: versioningLabels.length, + api_relevant_files: apiRelevantFiles, + unknown_files: unknownFiles, + only_non_api_files: onlyNonApiFiles, + violations + }; +} + +function writeArtifacts(result, outDir) { + fs.mkdirSync(outDir, { recursive: true }); + + const decision = { + schema_version: 1, + status: result.pass ? 'pass' : 'fail', + timestamp_utc: new Date().toISOString(), + selected_versioning_label: result.selected_versioning_label, + versioning_label_count: result.versioning_label_count, + only_non_api_files: result.only_non_api_files, + api_relevant_files: result.api_relevant_files, + unknown_files: result.unknown_files, + violations: result.violations + }; + + const summaryLines = [ + 'status\tcode\tmessage\tevidence', + ...(result.violations.length === 0 + ? ['pass\t-\tpolicy evaluation passed\t-'] + : result.violations.map((v) => `fail\t${v.code}\t${v.message}\t${v.evidence}`)) + ]; + + const actionsLines = [ + `POLICY_EVAL|status=${decision.status}`, + `POLICY_EVAL|versioning_label_count=${decision.versioning_label_count}`, + `POLICY_EVAL|selected_versioning_label=${decision.selected_versioning_label || '-'}`, + `POLICY_EVAL|api_relevant_files=${decision.api_relevant_files.length}`, + `POLICY_EVAL|unknown_files=${decision.unknown_files.length}` + ]; + + fs.writeFileSync(path.join(outDir, 'decision.json'), `${JSON.stringify(decision, null, 2)}\n`, 'utf-8'); + fs.writeFileSync(path.join(outDir, 'summary.tsv'), `${summaryLines.join('\n')}\n`, 'utf-8'); + fs.writeFileSync(path.join(outDir, 'actions.log'), `${actionsLines.join('\n')}\n`, 'utf-8'); +} + +function main() { + const files = parseJsonEnv('FILES_JSON', []); + const labels = parseJsonEnv('EXISTING_LABELS_JSON', []); + const outDir = process.env.OUT_DIR || 'artifacts/policy'; + + if (!Array.isArray(files)) { + throw new Error('FILES_JSON must be a JSON array'); + } + if (!Array.isArray(labels)) { + throw new Error('EXISTING_LABELS_JSON must be a JSON array'); + } + + const policy = readRacPolicy(); + const result = evaluate(files, labels, policy); + writeArtifacts(result, outDir); + + if (!result.pass) { + for (const violation of result.violations) { + console.error(`versioning-policy: ${violation.code}: ${violation.message}`); + } + process.exit(1); + } + + console.log('versioning-policy: pass'); +} + +if (require.main === module) { + main(); +} diff --git a/tools/versioning/label-schema.json b/tools/versioning/label-schema.json index 91375ab..61a1d84 100644 --- a/tools/versioning/label-schema.json +++ b/tools/versioning/label-schema.json @@ -13,10 +13,10 @@ "chore" ], "allowed_labels": [ - "version:major", - "version:minor", - "version:patch", - "version:none", + "versioning:major", + "versioning:minor", + "versioning:patch", + "versioning:none", "breaking", "feature", "fix", diff --git a/tools/versioning/labels.json b/tools/versioning/labels.json index c4a09de..a9d39f1 100644 --- a/tools/versioning/labels.json +++ b/tools/versioning/labels.json @@ -1,8 +1,12 @@ { - "version:major": {"color": "B60205", "description": "Breaking change; requires MAJOR bump"}, - "version:minor": {"color": "0E8A16", "description": "New compatible functionality; requires MINOR bump"}, - "version:patch": {"color": "1D76DB", "description": "Fix/Refactor/Docs/CI/Tooling; requires PATCH bump"}, - "version:none": {"color": "D4C5F9", "description": "No version bump required (meta-only change)"}, + "versioning:major": {"color": "B60205", "description": "Breaking change; requires MAJOR bump"}, + "versioning:minor": {"color": "0E8A16", "description": "New compatible functionality; requires MINOR bump"}, + "versioning:patch": {"color": "1D76DB", "description": "Fix/Refactor/Docs/CI/Tooling; requires PATCH bump"}, + "versioning:none": {"color": "D4C5F9", "description": "No version bump required (meta-only change)"}, + "version:major": {"color": "B60205", "description": "Legacy label (migration cleanup)"}, + "version:minor": {"color": "0E8A16", "description": "Legacy label (migration cleanup)"}, + "version:patch": {"color": "1D76DB", "description": "Legacy label (migration cleanup)"}, + "version:none": {"color": "D4C5F9", "description": "Legacy label (migration cleanup)"}, "breaking": {"color": "B60205", "description": "Primary label: public API/behavior breaking change"}, "feature": {"color": "0E8A16", "description": "Primary label: new compatible feature or datatype"}, diff --git a/tools/versioning/rac-policy.json b/tools/versioning/rac-policy.json new file mode 100644 index 0000000..ca8df6e --- /dev/null +++ b/tools/versioning/rac-policy.json @@ -0,0 +1,67 @@ +{ + "schema_version": 1, + "channels": { + "stable": { + "tag_regex": "^v\\d+\\.\\d+\\.\\d+$" + }, + "rc": { + "tag_regex": "^v\\d+\\.\\d+\\.\\d+-rc\\.\\d+$" + }, + "baseline": { + "description": "X.Y.0 baseline of latest stable minor" + } + }, + "semver": { + "strip_leading_v": true, + "stable_precedence_over_prerelease": true + }, + "enforcement": { + "fail_if_rc_matches_any_existing_stable_same_xyz": true, + "fail_if_rc_version_lte_latest_stable": true + }, + "labels": { + "versioning": { + "required_exactly_one": true, + "allowed": [ + "versioning:none", + "versioning:patch", + "versioning:minor", + "versioning:major" + ], + "map": { + "none": "versioning:none", + "patch": "versioning:patch", + "minor": "versioning:minor", + "major": "versioning:major" + } + }, + "informational": [ + "release:rc" + ] + }, + "classification": { + "api_relevant_prefixes": [ + "src/", + "src/FileTypeDetection/", + "src/FileClassifier.App/" + ], + "api_relevant_files": [ + "docs/010_API_CORE.MD", + "docs/references/001_REFERENCES_CORE.MD", + "docs/contracts/001_CONTRACT_HASHING.MD" + ], + "non_api_prefixes": [ + "docs/", + ".github/", + "tools/" + ], + "non_api_files": [ + "README.md" + ] + }, + "retention": { + "stable_keep": 2, + "baseline_keep": 1, + "rc_keep": 1 + } +} diff --git a/tools/versioning/run-versioning-policy.sh b/tools/versioning/run-versioning-policy.sh new file mode 100755 index 0000000..4cc9775 --- /dev/null +++ b/tools/versioning/run-versioning-policy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +PR_NUMBER="${PR_NUMBER:-}" +REPO="${REPO:-}" + +if [[ -z "${PR_NUMBER}" ]]; then + echo "versioning-policy: pull_request context required" >&2 + exit 1 +fi +if [[ -z "${REPO}" ]]; then + echo "versioning-policy: REPO is required" >&2 + exit 1 +fi + +mkdir -p artifacts/policy +files_json="$(python3 tools/ci/bin/github_api.py pr-files --repo "${REPO}" --pr "${PR_NUMBER}")" +labels_json="[]" +for _ in {1..12}; do + labels_json="$(python3 tools/ci/bin/github_api.py issue-labels --repo "${REPO}" --issue "${PR_NUMBER}")" + count="$(python3 -c 'import json,sys; labels=json.loads(sys.argv[1]); print(sum(1 for l in labels if isinstance(l, str) and l.startswith("versioning:")))' "${labels_json}")" + if [[ "${count}" -eq 1 ]]; then + break + fi + sleep 10 +done + +FILES_JSON="${files_json}" EXISTING_LABELS_JSON="${labels_json}" OUT_DIR="artifacts/policy" node tools/versioning/evaluate-versioning-policy.js diff --git a/tools/versioning/tag-gate.js b/tools/versioning/tag-gate.js new file mode 100755 index 0000000..240228c --- /dev/null +++ b/tools/versioning/tag-gate.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +function parseSemverCore(tag) { + const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function parseRc(tag) { + const m = /^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$/.exec(tag); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]; +} + +function cmpCore(a, b) { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; +} + +function stableTags() { + if (process.env.ALL_TAGS_JSON && process.env.ALL_TAGS_JSON.trim()) { + const parsed = JSON.parse(process.env.ALL_TAGS_JSON); + if (!Array.isArray(parsed)) { + throw new Error('ALL_TAGS_JSON must be a JSON array'); + } + return parsed.map((s) => String(s)); + } + const out = execSync("git tag -l 'v*'", { encoding: 'utf-8' }); + return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); +} + +function loadPolicy() { + const p = process.env.RAC_POLICY_PATH || 'tools/versioning/rac-policy.json'; + return JSON.parse(fs.readFileSync(path.resolve(p), 'utf-8')); +} + +function writeArtifacts(outDir, decision) { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, 'decision.json'), `${JSON.stringify(decision, null, 2)}\n`, 'utf-8'); + const tsv = ['status\tcode\tmessage']; + if (decision.violations.length === 0) { + tsv.push('pass\t-\ttag gate passed'); + } else { + for (const v of decision.violations) { + tsv.push(`fail\t${v.code}\t${v.message}`); + } + } + fs.writeFileSync(path.join(outDir, 'summary.tsv'), `${tsv.join('\n')}\n`, 'utf-8'); + const log = [ + `TAG_GATE|status=${decision.status}`, + `TAG_GATE|tag=${decision.tag}`, + `TAG_GATE|channel=${decision.channel}`, + `TAG_GATE|violations=${decision.violations.length}` + ]; + fs.writeFileSync(path.join(outDir, 'actions.log'), `${log.join('\n')}\n`, 'utf-8'); +} + +function main() { + const policy = loadPolicy(); + const stableRegex = new RegExp(policy.channels.stable.tag_regex); + const rcRegex = new RegExp(policy.channels.rc.tag_regex); + const tag = process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME || ''; + const outDir = process.env.OUT_DIR || 'artifacts/tag-gate'; + + const violations = []; + if (!tag) { + violations.push({ code: 'TG-TAG-MISSING', message: 'release tag missing' }); + } + + let channel = 'invalid'; + if (stableRegex.test(tag)) channel = 'stable'; + if (rcRegex.test(tag)) channel = 'rc'; + if (channel === 'invalid') { + violations.push({ code: 'TG-TAG-FORMAT', message: `tag '${tag}' does not match stable/rc policy regex` }); + } + + const allTags = stableTags(); + const stable = allTags.filter((t) => stableRegex.test(t)).map((t) => ({ tag: t, core: parseSemverCore(t) })).filter((x) => x.core); + stable.sort((a, b) => cmpCore(a.core, b.core)); + + if (channel === 'rc') { + const rc = parseRc(tag); + if (!rc) { + violations.push({ code: 'TG-RC-PARSE', message: `unable to parse rc tag '${tag}'` }); + } else { + const coreTag = `v${rc[0]}.${rc[1]}.${rc[2]}`; + if (policy.enforcement.fail_if_rc_matches_any_existing_stable_same_xyz && stable.some((s) => s.tag === coreTag)) { + violations.push({ code: 'TG-RC-COLLISION', message: `rc tag collides with existing stable ${coreTag}` }); + } + if (policy.enforcement.fail_if_rc_version_lte_latest_stable && stable.length > 0) { + const latestStable = stable[stable.length - 1].core; + const rcCore = [rc[0], rc[1], rc[2]]; + if (cmpCore(rcCore, latestStable) <= 0) { + violations.push({ code: 'TG-RC-ORDER', message: `rc core ${coreTag} must be greater than latest stable v${latestStable.join('.')}` }); + } + } + } + } + + const decision = { + schema_version: 1, + status: violations.length === 0 ? 'pass' : 'fail', + timestamp_utc: new Date().toISOString(), + tag, + channel, + stable_tag_count: stable.length, + violations + }; + + writeArtifacts(outDir, decision); + + if (violations.length > 0) { + for (const v of violations) { + console.error(`tag-gate: ${v.code}: ${v.message}`); + } + process.exit(1); + } + + console.log(`tag-gate: pass (${tag}, channel=${channel})`); +} + +if (require.main === module) { + main(); +} diff --git a/tools/versioning/test-evaluate-versioning-policy.js b/tools/versioning/test-evaluate-versioning-policy.js new file mode 100755 index 0000000..8f37dab --- /dev/null +++ b/tools/versioning/test-evaluate-versioning-policy.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); + +function runCase(name, files, labels, expectPass) { + const proc = spawnSync('node', ['tools/versioning/evaluate-versioning-policy.js'], { + env: { + ...process.env, + FILES_JSON: JSON.stringify(files), + EXISTING_LABELS_JSON: JSON.stringify(labels), + OUT_DIR: `artifacts/policy/test-${name}` + }, + encoding: 'utf-8' + }); + + const passed = proc.status === 0; + if (passed !== expectPass) { + throw new Error(`${name}: expected pass=${expectPass}, got pass=${passed}\nstdout=${proc.stdout}\nstderr=${proc.stderr}`); + } + console.log(`policy-test: OK -> ${name}`); +} + +function main() { + runCase('missing-versioning-label', ['docs/001_INDEX_CORE.MD'], ['docs'], false); + runCase('multiple-versioning-labels', ['docs/001_INDEX_CORE.MD'], ['versioning:none', 'versioning:patch'], false); + runCase('api-with-none', ['src/FileTypeDetection/FileTypeDetector.vb'], ['versioning:none'], false); + runCase('non-api-with-none', ['docs/001_INDEX_CORE.MD', '.github/workflows/ci.yml'], ['versioning:none'], true); + console.log('policy-test: completed 4 testcase(s)'); +} + +main(); diff --git a/tools/versioning/test-tag-gate.js b/tools/versioning/test-tag-gate.js new file mode 100755 index 0000000..16a1a9b --- /dev/null +++ b/tools/versioning/test-tag-gate.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('child_process'); + +function runCase(name, releaseTag, allTags, expectPass) { + const proc = spawnSync('node', ['tools/versioning/tag-gate.js'], { + env: { + ...process.env, + RELEASE_TAG: releaseTag, + ALL_TAGS_JSON: JSON.stringify(allTags), + OUT_DIR: `artifacts/tag-gate/test-${name}` + }, + encoding: 'utf-8' + }); + const pass = proc.status === 0; + if (pass !== expectPass) { + throw new Error(`${name}: expected pass=${expectPass}, got pass=${pass}\nstdout=${proc.stdout}\nstderr=${proc.stderr}`); + } + console.log(`tag-gate-test: OK -> ${name}`); +} + +function main() { + runCase('stable-pass', 'v5.2.0', ['v5.1.0', 'v5.0.0', 'v5.2.0'], true); + runCase('rc-collision-fail', 'v5.1.0-rc.1', ['v5.1.0', 'v5.0.0'], false); + runCase('rc-order-fail', 'v5.0.9-rc.1', ['v5.1.0', 'v5.0.8'], false); + runCase('rc-pass', 'v5.2.0-rc.1', ['v5.1.0', 'v5.0.9'], true); + console.log('tag-gate-test: completed 4 testcase(s)'); +} + +main(); diff --git a/tools/versioning/testcases/01-docs-only.json b/tools/versioning/testcases/01-docs-only.json index 94df173..2b1bfb0 100644 --- a/tools/versioning/testcases/01-docs-only.json +++ b/tools/versioning/testcases/01-docs-only.json @@ -9,7 +9,7 @@ "version_reason": "docs-test-ci-tooling", "pr_title": "docs: update ci docs", "expect": { - "version_label": "version:patch", + "version_label": "versioning:patch", "primary": "docs", "impl": "impl:docs", "area_contains": [ diff --git a/tools/versioning/testcases/02-ci-only.json b/tools/versioning/testcases/02-ci-only.json index 1f69bcf..6ea34f1 100644 --- a/tools/versioning/testcases/02-ci-only.json +++ b/tools/versioning/testcases/02-ci-only.json @@ -6,7 +6,7 @@ "version_reason": "pipeline-or-tooling-changed", "pr_title": "ci: adjust pipeline", "expect": { - "version_label": "version:patch", + "version_label": "versioning:patch", "primary": "ci", "impl": "impl:config", "area_contains": ["area:pipeline"] diff --git a/tools/versioning/testcases/03-src-tests-docs.json b/tools/versioning/testcases/03-src-tests-docs.json index cebea6b..f5c08a9 100644 --- a/tools/versioning/testcases/03-src-tests-docs.json +++ b/tools/versioning/testcases/03-src-tests-docs.json @@ -10,7 +10,7 @@ "version_reason": "code-surface-changed", "pr_title": "feat: improve detector behavior", "expect": { - "version_label": "version:minor", + "version_label": "versioning:minor", "primary": "feature", "impl": "impl:quality", "area_exact": ["area:detection", "area:tests"] diff --git a/tools/versioning/testcases/04-breaking.json b/tools/versioning/testcases/04-breaking.json index 26ca9d9..bd86a0d 100644 --- a/tools/versioning/testcases/04-breaking.json +++ b/tools/versioning/testcases/04-breaking.json @@ -6,7 +6,7 @@ "version_reason": "breaking-change-required", "pr_title": "refactor!: rename public api", "expect": { - "version_label": "version:major", + "version_label": "versioning:major", "primary": "breaking", "impl": "impl:quality", "area_contains": ["area:archive"] diff --git a/tools/versioning/testcases/05-scope-switch.json b/tools/versioning/testcases/05-scope-switch.json index df3f2bf..266964f 100644 --- a/tools/versioning/testcases/05-scope-switch.json +++ b/tools/versioning/testcases/05-scope-switch.json @@ -5,9 +5,9 @@ "actual": "patch", "version_reason": "pipeline-or-tooling-changed", "pr_title": "chore: tighten versioning tooling", - "existing_labels": ["feature", "impl:quality", "area:detection", "version:minor"], + "existing_labels": ["feature", "impl:quality", "area:detection", "versioning:minor"], "expect": { - "version_label": "version:patch", + "version_label": "versioning:patch", "primary": "ci", "impl": "impl:config", "area_exact": ["area:tooling", "area:pipeline"] diff --git a/tools/versioning/testcases/06-guard-fallback.json b/tools/versioning/testcases/06-guard-fallback.json index e1f3ba6..296d613 100644 --- a/tools/versioning/testcases/06-guard-fallback.json +++ b/tools/versioning/testcases/06-guard-fallback.json @@ -6,7 +6,7 @@ "version_reason": "guard-unavailable-fallback", "pr_title": "chore: metadata", "expect": { - "version_label": "version:none", + "version_label": "versioning:none", "primary": "chore", "impl": "impl:config", "area_contains": ["area:versioning"] diff --git a/tools/versioning/validate-label-decision.js b/tools/versioning/validate-label-decision.js index 9c713ab..e772791 100644 --- a/tools/versioning/validate-label-decision.js +++ b/tools/versioning/validate-label-decision.js @@ -50,9 +50,9 @@ function validate() { } } - const versionLabels = decision.labels_to_add.filter((label) => label.startsWith('version:')); + const versionLabels = decision.labels_to_add.filter((label) => label.startsWith('versioning:')); if (versionLabels.length !== 1) { - fail(`expected exactly 1 version:* label, got ${versionLabels.length}`); + fail(`expected exactly 1 versioning:* label, got ${versionLabels.length}`); } const primaryLabels = decision.labels_to_add.filter((label) => (schema.primary_labels || []).includes(label));