diff --git a/.github/actions/setup-snyk/action.yml b/.github/actions/setup-snyk/action.yml new file mode 100644 index 00000000..d96d23cf --- /dev/null +++ b/.github/actions/setup-snyk/action.yml @@ -0,0 +1,23 @@ +name: Setup Snyk +description: Install Node.js and the pinned Snyk CLI for workflow jobs + +inputs: + node-version: + description: Node.js version to install + required: true + snyk-version: + description: Snyk CLI version to install + required: true + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ inputs.node-version }} + + - name: Install Snyk CLI + uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0 + with: + snyk-version: ${{ inputs.snyk-version }} diff --git a/.github/actions/wait-for-successful-branch-ci/action.yml b/.github/actions/wait-for-successful-branch-ci/action.yml new file mode 100644 index 00000000..b7d9908a --- /dev/null +++ b/.github/actions/wait-for-successful-branch-ci/action.yml @@ -0,0 +1,92 @@ +name: Wait For Successful Branch CI +description: Resolve a workflow by file name and wait for a successful branch push run on a target SHA + +inputs: + github-token: + description: GitHub token with actions read access + required: true + workflow-file: + description: Workflow file name to resolve, for example ci-verify.yml + required: true + target-sha: + description: Commit SHA that must have a successful branch push run + required: true + max-attempts: + description: Maximum polling attempts before timing out + required: false + default: '20' + sleep-seconds: + description: Seconds to sleep between polling attempts + required: false + default: '15' + +runs: + using: composite + steps: + - name: Resolve workflow reference + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + WORKFLOW_FILE: ${{ inputs.workflow-file }} + run: | + set -euo pipefail + + workflow_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW_FILE}" + workflow_json="$(curl -fsSL \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${workflow_url}")" + + workflow_id="$(echo "${workflow_json}" | jq -r '.id // empty')" + workflow_name="$(echo "${workflow_json}" | jq -r '.name // empty')" + workflow_path="$(echo "${workflow_json}" | jq -r '.path // empty')" + if [ -z "${workflow_id}" ] || [ -z "${workflow_path}" ]; then + echo "::error::Failed to resolve workflow metadata for ${WORKFLOW_FILE}." + exit 1 + fi + + { + echo "workflow_id=${workflow_id}" + echo "workflow_name=${workflow_name}" + echo "workflow_path=${workflow_path}" + } >> "$GITHUB_ENV" + echo "Using CI verification workflow: ${workflow_name} (${workflow_path}, id=${workflow_id})" + + - name: Wait for successful branch CI on target SHA + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TARGET_SHA: ${{ inputs.target-sha }} + MAX_ATTEMPTS: ${{ inputs.max-attempts }} + SLEEP_SECONDS: ${{ inputs.sleep-seconds }} + run: | + set -euo pipefail + + for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + runs_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_id}/runs?head_sha=${TARGET_SHA}&event=push&per_page=50" + runs_json="$(curl -fsSL \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${runs_url}")" + + success_count="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.conclusion == "success" and ((.head_branch // "") | length > 0))] | length')" + in_progress_count="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.status != "completed" and ((.head_branch // "") | length > 0))] | length')" + completed_count="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.status == "completed" and ((.head_branch // "") | length > 0))] | length')" + + if [ "${success_count}" -gt 0 ]; then + echo "Found successful ${workflow_name} branch push run for ${TARGET_SHA}." + exit 0 + fi + + if [ "${completed_count}" -gt 0 ] && [ "${in_progress_count}" -eq 0 ]; then + echo "::error::${workflow_name} branch push runs for ${TARGET_SHA} completed without success." + echo "${runs_json}" | jq '.workflow_runs[] | select((.head_branch // "") | length > 0) | {id, status, conclusion, head_branch, html_url}' + exit 1 + fi + + echo "Attempt ${attempt}/${MAX_ATTEMPTS}: waiting for successful ${workflow_name} branch push run on ${TARGET_SHA}..." + sleep "${SLEEP_SECONDS}" + done + + echo "::error::Timed out waiting for successful ${workflow_name} branch push run on ${TARGET_SHA}." + exit 1 diff --git a/.github/workflows/ci-verify.yml b/.github/workflows/ci-verify.yml index a9d17732..007cb19c 100644 --- a/.github/workflows/ci-verify.yml +++ b/.github/workflows/ci-verify.yml @@ -30,8 +30,7 @@ concurrency: group: ci-verify-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read +permissions: {} jobs: zizmor: @@ -1072,7 +1071,10 @@ jobs: run: | set -euo pipefail - latest_tag="$(git tag --list 'v*' --sort=-v:refname | head -n1)" + # Resolve latest stable tag (skip pre-release tags like v1.5.0-rc.2 + # since release-next-version.mjs only accepts X.Y.Z). + latest_tag="$(git tag --list 'v*' --sort=-v:refname \ + | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" if [ -z "${latest_tag}" ]; then latest_tag="v0.0.0" fi @@ -1084,28 +1086,41 @@ jobs: next_version="0.0.1" else set +e - mapfile -t vars < <(node scripts/release-next-version.mjs \ + output="$(node scripts/release-next-version.mjs \ --current "${current_version}" \ --bump auto \ --from "${latest_tag}" \ - --to "${TARGET_SHA}") + --to "${TARGET_SHA}" 2>&1)" rc=$? set -e if [ "${rc}" -ne 0 ]; then - echo "No releasable commits found; skipping auto tag." - echo "should_tag=false" >> "$GITHUB_OUTPUT" - exit 0 + # Distinguish "no releasable commits" (exit 1 with known message) + # from unexpected errors that should fail the job. + if echo "${output}" | grep -qi "no releasable commits"; then + echo "No releasable commits since ${latest_tag}; skipping auto tag." + echo "should_tag=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::release-next-version.mjs failed: ${output}" + exit 1 fi - for kv in "${vars[@]}"; do + release_level="" + next_version="" + while IFS= read -r kv; do key="${kv%%=*}" value="${kv#*=}" case "${key}" in release_level) release_level="${value}" ;; next_version) next_version="${value}" ;; esac - done + done <<< "${output}" + + if [ -z "${next_version}" ]; then + echo "::error::release-next-version.mjs succeeded but produced no next_version. Output: ${output}" + exit 1 + fi fi release_tag="v${next_version}" diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index d1e6b59f..baa93127 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -4,9 +4,7 @@ run-name: "🏷️ Release: Cut — manual by ${{ github.actor }}" on: workflow_dispatch: -permissions: - contents: write - actions: read +permissions: {} concurrency: group: release-cut @@ -20,6 +18,9 @@ jobs: name: "🏷️ Release: Cut Tag" runs-on: ubuntu-latest timeout-minutes: 20 # Includes CI-success polling for target SHA before tagging + permissions: + contents: write + actions: read steps: - name: Harden Runner uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 @@ -40,80 +41,23 @@ jobs: echo "sha=${sha}" >> "$GITHUB_OUTPUT" echo "Target SHA: ${sha}" - - name: Resolve CI workflow reference - id: ci_workflow - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - workflow_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${CI_VERIFY_WORKFLOW_FILE}" - workflow_json="$(curl -fsSL \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "${workflow_url}")" - - workflow_id="$(echo "${workflow_json}" | jq -r '.id // empty')" - workflow_name="$(echo "${workflow_json}" | jq -r '.name // empty')" - workflow_path="$(echo "${workflow_json}" | jq -r '.path // empty')" - if [ -z "${workflow_id}" ] || [ -z "${workflow_path}" ]; then - echo "::error::Failed to resolve workflow metadata for ${CI_VERIFY_WORKFLOW_FILE}." - exit 1 - fi - - { - echo "id=${workflow_id}" - echo "name=${workflow_name}" - echo "path=${workflow_path}" - } >> "$GITHUB_OUTPUT" - echo "Using CI verification workflow: ${workflow_name} (${workflow_path}, id=${workflow_id})" - - name: Verify successful branch CI on target SHA - env: - GH_TOKEN: ${{ github.token }} - CI_WORKFLOW_ID: ${{ steps.ci_workflow.outputs.id }} - CI_WORKFLOW_NAME: ${{ steps.ci_workflow.outputs.name }} - TARGET_SHA: ${{ steps.target.outputs.sha }} - run: | - set -euo pipefail - - max_attempts=20 - sleep_seconds=15 - - for attempt in $(seq 1 "${max_attempts}"); do - runs_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${CI_WORKFLOW_ID}/runs?head_sha=${TARGET_SHA}&event=push&per_page=50" - runs_json="$(curl -fsSL \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "${runs_url}")" - - successful_runs="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.conclusion == "success" and ((.head_branch // "") | length > 0))] | length')" - in_progress_runs="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.status != "completed" and ((.head_branch // "") | length > 0))] | length')" - completed_runs="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.status == "completed" and ((.head_branch // "") | length > 0))] | length')" - - if [ "${successful_runs}" -gt 0 ]; then - echo "Found successful ${CI_WORKFLOW_NAME} branch push run for ${TARGET_SHA}." - exit 0 - fi - - if [ "${completed_runs}" -gt 0 ] && [ "${in_progress_runs}" -eq 0 ]; then - echo "::error::${CI_WORKFLOW_NAME} branch push runs for ${TARGET_SHA} completed without success." - echo "${runs_json}" | jq '.workflow_runs[] | select((.head_branch // "") | length > 0) | {id, status, conclusion, head_branch, html_url}' - exit 1 - fi - - echo "Attempt ${attempt}/${max_attempts}: waiting for successful ${CI_WORKFLOW_NAME} branch push run on ${TARGET_SHA}..." - sleep "${sleep_seconds}" - done - - echo "::error::Timed out waiting for successful ${CI_WORKFLOW_NAME} branch push run on ${TARGET_SHA}." - exit 1 + uses: ./.github/actions/wait-for-successful-branch-ci + with: + github-token: ${{ github.token }} + workflow-file: ${{ env.CI_VERIFY_WORKFLOW_FILE }} + target-sha: ${{ steps.target.outputs.sha }} + max-attempts: '20' + sleep-seconds: '15' - name: Find latest release tag id: base run: | set -euo pipefail - latest_tag="$(git tag --list 'v*' --sort=-v:refname | head -n1)" + # Resolve latest stable tag (skip pre-release tags like v1.5.0-rc.2 + # since release-next-version.mjs only accepts X.Y.Z). + latest_tag="$(git tag --list 'v*' --sort=-v:refname \ + | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" if [ -z "${latest_tag}" ]; then latest_tag="v0.0.0" fi @@ -135,20 +79,39 @@ jobs: release_level="patch" next_version="0.0.1" else - mapfile -t vars < <(node scripts/release-next-version.mjs \ + set +e + output="$(node scripts/release-next-version.mjs \ --current "${CURRENT_VERSION}" \ --bump "${BUMP}" \ --from "${LATEST_TAG}" \ - --to "${TARGET_SHA}") + --to "${TARGET_SHA}" 2>&1)" + rc=$? + set -e + + if [ "${rc}" -ne 0 ]; then + if echo "${output}" | grep -qi "no releasable commits"; then + echo "::error::No releasable commits since ${LATEST_TAG}; refusing to cut a release tag." + else + echo "::error::release-next-version.mjs failed: ${output}" + fi + exit 1 + fi - for kv in "${vars[@]}"; do + release_level="" + next_version="" + while IFS= read -r kv; do key="${kv%%=*}" value="${kv#*=}" case "${key}" in release_level) release_level="${value}" ;; next_version) next_version="${value}" ;; esac - done + done <<< "${output}" + + if [ -z "${release_level}" ] || [ -z "${next_version}" ]; then + echo "::error::release-next-version.mjs succeeded but returned incomplete output. Output: ${output}" + exit 1 + fi fi release_tag="v${next_version}" diff --git a/.github/workflows/release-from-tag.yml b/.github/workflows/release-from-tag.yml index 4153dc21..ca7902f3 100644 --- a/.github/workflows/release-from-tag.yml +++ b/.github/workflows/release-from-tag.yml @@ -5,7 +5,7 @@ on: push: tags: ['v*'] -permissions: read-all +permissions: {} env: DOCKER_PLATFORMS: linux/amd64,linux/arm64 @@ -20,6 +20,10 @@ jobs: name: "✅ Release: Verify Prior CI Success" runs-on: ubuntu-latest timeout-minutes: 20 # Poll loop waits for prior branch CI status on tag SHA + outputs: + base_version: ${{ steps.tag.outputs.base_version }} + is_prerelease: ${{ steps.tag.outputs.is_prerelease }} + prerelease: ${{ steps.tag.outputs.prerelease }} permissions: contents: read actions: read @@ -30,52 +34,28 @@ jobs: with: egress-policy: audit - - name: Resolve CI workflow reference - id: ci_workflow - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - workflow_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${CI_VERIFY_WORKFLOW_FILE}" - workflow_json="$(curl -fsSL \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "${workflow_url}")" - - workflow_id="$(echo "${workflow_json}" | jq -r '.id // empty')" - workflow_name="$(echo "${workflow_json}" | jq -r '.name // empty')" - workflow_path="$(echo "${workflow_json}" | jq -r '.path // empty')" - if [ -z "${workflow_id}" ] || [ -z "${workflow_path}" ]; then - echo "::error::Failed to resolve workflow metadata for ${CI_VERIFY_WORKFLOW_FILE}." - exit 1 - fi - - { - echo "id=${workflow_id}" - echo "name=${workflow_name}" - echo "path=${workflow_path}" - } >> "$GITHUB_OUTPUT" - echo "Using CI verification workflow: ${workflow_name} (${workflow_path}, id=${workflow_id})" - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Assert tag version matches package versions + - name: Parse release tag + id: tag run: | set -euo pipefail - tag_version="${GITHUB_REF_NAME#v}" - if [ -z "${tag_version}" ] || [ "${tag_version}" = "${GITHUB_REF_NAME}" ]; then - echo "::error::Tag '${GITHUB_REF_NAME}' does not match expected format vX.Y.Z[-prerelease]." - exit 1 - fi + output="$(node scripts/release-tag.mjs --tag "${GITHUB_REF_NAME}")" + while IFS= read -r kv; do + key="${kv%%=*}" + value="${kv#*=}" + echo "${key}=${value}" >> "$GITHUB_OUTPUT" + done <<< "${output}" - # For prereleases (rc, nightly), compare base version only: - # v1.5.0-rc.1 → compare 1.5.0 against package.json - base_version="${tag_version%%-*}" + - name: Assert tag version matches package versions + env: + BASE_VERSION: ${{ steps.tag.outputs.base_version }} + run: | + set -euo pipefail for package_path in package.json app/package.json ui/package.json; do package_version="$(jq -r '.version // empty' "${package_path}")" @@ -83,53 +63,22 @@ jobs: echo "::error::Missing required version field in ${package_path}" exit 1 fi - if [ "${package_version}" != "${base_version}" ]; then - echo "::error::Version mismatch: tag base=${base_version} (from ${GITHUB_REF_NAME}), ${package_path}=${package_version}" + if [ "${package_version}" != "${BASE_VERSION}" ]; then + echo "::error::Version mismatch: tag base=${BASE_VERSION} (from ${GITHUB_REF_NAME}), ${package_path}=${package_version}" exit 1 fi done - echo "Tag version ${base_version} matches package.json, app/package.json, and ui/package.json." + echo "Tag version ${BASE_VERSION} matches package.json, app/package.json, and ui/package.json." - name: Wait for successful branch CI on tag SHA - env: - GH_TOKEN: ${{ github.token }} - CI_WORKFLOW_ID: ${{ steps.ci_workflow.outputs.id }} - CI_WORKFLOW_NAME: ${{ steps.ci_workflow.outputs.name }} - run: | - set -euo pipefail - - max_attempts=25 - sleep_seconds=60 - - for attempt in $(seq 1 "${max_attempts}"); do - runs_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${CI_WORKFLOW_ID}/runs?head_sha=${GITHUB_SHA}&event=push&per_page=50" - runs_json="$(curl -fsSL \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "${runs_url}")" - - success_count="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.conclusion == "success" and ((.head_branch // "") | length > 0))] | length')" - in_progress_count="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.status != "completed" and ((.head_branch // "") | length > 0))] | length')" - completed_count="$(echo "${runs_json}" | jq '[.workflow_runs[] | select(.status == "completed" and ((.head_branch // "") | length > 0))] | length')" - - if [ "${success_count}" -gt 0 ]; then - echo "Found successful ${CI_WORKFLOW_NAME} push run for ${GITHUB_SHA}." - exit 0 - fi - - if [ "${completed_count}" -gt 0 ] && [ "${in_progress_count}" -eq 0 ]; then - echo "::error::${CI_WORKFLOW_NAME} branch push runs for ${GITHUB_SHA} completed without success." - echo "${runs_json}" | jq '.workflow_runs[] | select((.head_branch // "") | length > 0) | {id, status, conclusion, head_branch, html_url}' - exit 1 - fi - - echo "Attempt ${attempt}/${max_attempts}: waiting for ${CI_WORKFLOW_NAME} branch push run on ${GITHUB_SHA}..." - sleep "${sleep_seconds}" - done - - echo "::error::Timed out waiting for successful ${CI_WORKFLOW_NAME} branch push run on ${GITHUB_SHA}." - exit 1 + uses: ./.github/actions/wait-for-successful-branch-ci + with: + github-token: ${{ github.token }} + workflow-file: ${{ env.CI_VERIFY_WORKFLOW_FILE }} + target-sha: ${{ github.sha }} + max-attempts: '25' + sleep-seconds: '60' release: name: "🚀 Release: Docker Build & Push" @@ -418,6 +367,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/') id: release_notes env: + IS_PRERELEASE: ${{ needs.verify-ci.outputs.is_prerelease }} RELEASE_TAG: ${{ github.ref_name }} run: | set -euo pipefail @@ -428,7 +378,7 @@ jobs: missing_heading="## [${RELEASE_TAG#v}] - YYYY-MM-DD" if ! node scripts/extract-changelog-entry.mjs --version "${RELEASE_TAG}" --file CHANGELOG.md > "${entry_path}"; then # For pre-releases (rc, beta, alpha), try [Unreleased] as fallback - if [[ "${RELEASE_TAG}" == *-* ]] && node scripts/extract-changelog-entry.mjs --version "Unreleased" --file CHANGELOG.md > "${entry_path}" 2>/dev/null; then + if [ "${IS_PRERELEASE}" = "true" ] && node scripts/extract-changelog-entry.mjs --version "Unreleased" --file CHANGELOG.md > "${entry_path}" 2>/dev/null; then echo "Using [Unreleased] changelog section for pre-release ${RELEASE_TAG}." else rm -f "${entry_path}" "${notes_path}" @@ -453,14 +403,14 @@ jobs: if: startsWith(github.ref, 'refs/tags/') env: GH_TOKEN: ${{ github.token }} + IS_PRERELEASE: ${{ needs.verify-ci.outputs.is_prerelease }} RELEASE_TAG: ${{ github.ref_name }} RELEASE_NOTES_PATH: ${{ steps.release_notes.outputs.path }} run: | - # Mark RC and nightly tags as prerelease on GitHub prerelease_flag="" - case "${RELEASE_TAG}" in - *-rc.*) prerelease_flag="--prerelease" ;; - esac + if [ "${IS_PRERELEASE}" = "true" ]; then + prerelease_flag="--prerelease" + fi gh release view "${RELEASE_TAG}" >/dev/null 2>&1 || gh release create "${RELEASE_TAG}" --title "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_PATH}" ${prerelease_flag} gh release edit "${RELEASE_TAG}" --title "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_PATH}" ${prerelease_flag} diff --git a/.github/workflows/security-snyk-weekly.yml b/.github/workflows/security-snyk-weekly.yml index 9cf4faa3..a0145b64 100644 --- a/.github/workflows/security-snyk-weekly.yml +++ b/.github/workflows/security-snyk-weekly.yml @@ -102,13 +102,11 @@ jobs: with: persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - name: Setup Snyk toolchain + uses: ./.github/actions/setup-snyk with: - node-version: 24 - - - name: Install Snyk CLI - run: npm install -g "snyk@${SNYK_CLI_VERSION}" + node-version: '24' + snyk-version: v${{ env.SNYK_CLI_VERSION }} - name: Run Snyk Open Source scans run: | @@ -140,13 +138,11 @@ jobs: with: persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - name: Setup Snyk toolchain + uses: ./.github/actions/setup-snyk with: - node-version: 24 - - - name: Install Snyk CLI - run: npm install -g "snyk@${SNYK_CLI_VERSION}" + node-version: '24' + snyk-version: v${{ env.SNYK_CLI_VERSION }} - name: Run Snyk Code scan run: ./scripts/snyk-code-gate.sh @@ -172,13 +168,11 @@ jobs: with: persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - name: Setup Snyk toolchain + uses: ./.github/actions/setup-snyk with: - node-version: 24 - - - name: Install Snyk CLI - run: npm install -g "snyk@${SNYK_CLI_VERSION}" + node-version: '24' + snyk-version: v${{ env.SNYK_CLI_VERSION }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -218,13 +212,11 @@ jobs: with: persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - name: Setup Snyk toolchain + uses: ./.github/actions/setup-snyk with: - node-version: 24 - - - name: Install Snyk CLI - run: npm install -g "snyk@${SNYK_CLI_VERSION}" + node-version: '24' + snyk-version: v${{ env.SNYK_CLI_VERSION }} - name: Run Snyk IaC scan run: ./scripts/snyk-iac-gate.sh . diff --git a/scripts/release-next-version.mjs b/scripts/release-next-version.mjs index b28e7198..d9630aa8 100644 --- a/scripts/release-next-version.mjs +++ b/scripts/release-next-version.mjs @@ -17,6 +17,23 @@ const PATCH_TYPES = new Set([ const conventionalSubjectRegex = /^(?:\S+\s+)?(?feat|fix|docs|style|refactor|perf|test|chore|security|deps|revert)(?!)?(?:\([^)]+\))?(?!)?:\s.+$/u; +const stableVersionRegex = /^v?(?\d+)\.(?\d+)\.(?\d+)$/u; +const explicitReleaseVersionRegex = /(?:^|[/: \t])v(?\d+\.\d+\.\d+)(?![0-9A-Za-z.-])/u; + +function parseStableVersion(version) { + const match = String(version ?? '') + .trim() + .match(stableVersionRegex); + if (!match?.groups) { + throw new Error(`Invalid current version: ${version}`); + } + + return { + major: Number(match.groups.major), + minor: Number(match.groups.minor), + patch: Number(match.groups.patch), + }; +} export function inferReleaseLevel(commits) { let hasFeat = false; @@ -63,16 +80,7 @@ export function inferReleaseLevel(commits) { } export function bumpSemver(currentVersion, level) { - const match = String(currentVersion ?? '') - .trim() - .match(/^v?(?\d+)\.(?\d+)\.(?\d+)$/u); - if (!match?.groups) { - throw new Error(`Invalid current version: ${currentVersion}`); - } - - const major = Number(match.groups.major); - const minor = Number(match.groups.minor); - const patch = Number(match.groups.patch); + const { major, minor, patch } = parseStableVersion(currentVersion); if (level === 'major') { return `${major + 1}.0.0`; @@ -87,6 +95,62 @@ export function bumpSemver(currentVersion, level) { throw new Error(`Invalid release level: ${level}`); } +function inferExplicitReleaseVersion(commits) { + for (const commit of commits) { + const message = String(commit ?? '').trim(); + if (!message) { + continue; + } + + const subject = message.split(/\r?\n/u, 1)[0] ?? ''; + const match = subject.match(explicitReleaseVersionRegex); + if (match?.groups?.version) { + return match.groups.version; + } + } + + return null; +} + +function inferReleaseLevelFromVersions(currentVersion, nextVersion) { + const current = parseStableVersion(currentVersion); + const next = parseStableVersion(nextVersion); + + if (next.major > current.major) { + return 'major'; + } + if (next.major === current.major && next.minor > current.minor) { + return 'minor'; + } + if (next.major === current.major && next.minor === current.minor && next.patch > current.patch) { + return 'patch'; + } + + throw new Error( + `Explicit release version ${nextVersion} is not newer than current version ${currentVersion}`, + ); +} + +export function resolveAutoRelease(currentVersion, commits) { + const explicitReleaseVersion = inferExplicitReleaseVersion(commits); + if (explicitReleaseVersion) { + return { + releaseLevel: inferReleaseLevelFromVersions(currentVersion, explicitReleaseVersion), + nextVersion: explicitReleaseVersion, + }; + } + + const releaseLevel = inferReleaseLevel(commits); + if (!releaseLevel) { + return null; + } + + return { + releaseLevel, + nextVersion: bumpSemver(currentVersion, releaseLevel), + }; +} + function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i += 1) { @@ -133,10 +197,16 @@ function main() { throw new Error('--from is required when --bump auto'); } const commits = getCommitMessages(fromRef, toRef); - releaseLevel = inferReleaseLevel(commits); - if (!releaseLevel) { + const resolved = resolveAutoRelease(current, commits); + if (!resolved) { throw new Error('No releasable commits found between refs'); } + + releaseLevel = resolved.releaseLevel; + const nextVersion = resolved.nextVersion; + console.log(`release_level=${releaseLevel}`); + console.log(`next_version=${nextVersion}`); + return; } const nextVersion = bumpSemver(current, releaseLevel); diff --git a/scripts/release-next-version.test.mjs b/scripts/release-next-version.test.mjs index 99fb0c2f..f49f23be 100644 --- a/scripts/release-next-version.test.mjs +++ b/scripts/release-next-version.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import test from 'node:test'; -import { bumpSemver, inferReleaseLevel } from './release-next-version.mjs'; +import { fileURLToPath } from 'node:url'; +import { bumpSemver, inferReleaseLevel, resolveAutoRelease } from './release-next-version.mjs'; + +const scriptPath = fileURLToPath(new URL('./release-next-version.mjs', import.meta.url)); test('infers minor when at least one feat commit exists', () => { const level = inferReleaseLevel([ @@ -46,3 +50,53 @@ test('bumps minor versions', () => { test('bumps major versions', () => { assert.equal(bumpSemver('1.4.9', 'major'), '2.0.0'); }); + +test('accepts v-prefixed versions', () => { + assert.equal(bumpSemver('v1.4.9', 'patch'), '1.4.10'); +}); + +test('throws for invalid current versions', () => { + assert.throws(() => bumpSemver('1.4', 'patch'), /Invalid current version: 1\.4/u); +}); + +test('throws for invalid release levels', () => { + assert.throws(() => bumpSemver('1.4.9', 'prerelease'), /Invalid release level: prerelease/u); +}); + +test('prefers an explicit stable release commit over later patch-level commits', () => { + const result = resolveAutoRelease('1.4.5', [ + '🔒 security(ci): fix Scorecard alerts + auto-tag pre-release crash', + 'v1.5.0 — Observability, Dashboard Customization & Hardening (#196)', + ]); + + assert.deepEqual(result, { + releaseLevel: 'minor', + nextVersion: '1.5.0', + }); +}); + +test('ignores prerelease branch names when resolving an explicit stable release', () => { + const result = resolveAutoRelease('1.4.5', [ + 'Merge pull request #142 from CodesWhat/release/v1.4.0-rc.13', + '🐛 fix(api): resolve edge case', + ]); + + assert.deepEqual(result, { + releaseLevel: 'patch', + nextVersion: '1.4.6', + }); +}); + +test('cli reports "no releasable commits" for empty auto-release ranges', () => { + const result = spawnSync( + process.execPath, + [scriptPath, '--current', '1.4.5', '--bump', 'auto', '--from', 'HEAD', '--to', 'HEAD'], + { + cwd: process.cwd(), + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /no releasable commits/i); +}); diff --git a/scripts/release-tag.mjs b/scripts/release-tag.mjs new file mode 100644 index 00000000..d0cf02e0 --- /dev/null +++ b/scripts/release-tag.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +const releaseTagRegex = + /^v(?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?:-(?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u; +const numericIdentifierRegex = /^(?:0|[1-9]\d*)$/u; +const digitsOnlyRegex = /^\d+$/u; +const legacyRcRegex = /^rc(?\d+)$/u; + +function validatePrerelease(prerelease, tag) { + if (!prerelease) { + return; + } + + for (const identifier of prerelease.split('.')) { + if (digitsOnlyRegex.test(identifier) && !numericIdentifierRegex.test(identifier)) { + throw new Error( + `Invalid prerelease identifier in ${tag}: ${identifier}. Numeric identifiers must not contain leading zeroes.`, + ); + } + } + + const legacyRcMatch = prerelease.match(legacyRcRegex); + if (legacyRcMatch?.groups?.number) { + const canonicalRcNumber = String(Number(legacyRcMatch.groups.number)); + throw new Error( + `Invalid RC tag format: ${tag}. Use v${tag.slice(1, tag.lastIndexOf('-'))}-rc.${canonicalRcNumber} instead.`, + ); + } +} + +export function parseReleaseTag(tag) { + const value = String(tag ?? '').trim(); + const match = value.match(releaseTagRegex); + if (!match?.groups) { + throw new Error(`Invalid release tag: ${tag}. Use vX.Y.Z or vX.Y.Z-.`); + } + + const prerelease = match.groups.prerelease ?? null; + validatePrerelease(prerelease, value); + + return { + tag: value, + baseVersion: match.groups.baseVersion, + prerelease, + isPrerelease: prerelease !== null, + }; +} + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + const value = argv[i + 1]; + if (!key.startsWith('--')) { + continue; + } + if (value === undefined || value.startsWith('--')) { + throw new Error(`Missing value for argument: ${key}`); + } + args[key.slice(2)] = value; + i += 1; + } + return args; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.tag) { + throw new Error('--tag is required'); + } + + const metadata = parseReleaseTag(args.tag); + console.log(`base_version=${metadata.baseVersion}`); + console.log(`is_prerelease=${metadata.isPrerelease}`); + console.log(`prerelease=${metadata.prerelease ?? ''}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/release-tag.test.mjs b/scripts/release-tag.test.mjs new file mode 100644 index 00000000..5c292eac --- /dev/null +++ b/scripts/release-tag.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parseReleaseTag } from './release-tag.mjs'; + +const scriptPath = fileURLToPath(new URL('./release-tag.mjs', import.meta.url)); + +test('parses stable release tags', () => { + assert.deepEqual(parseReleaseTag('v1.5.0'), { + tag: 'v1.5.0', + baseVersion: '1.5.0', + prerelease: null, + isPrerelease: false, + }); +}); + +test('parses canonical rc prerelease tags', () => { + assert.deepEqual(parseReleaseTag('v1.5.0-rc.2'), { + tag: 'v1.5.0-rc.2', + baseVersion: '1.5.0', + prerelease: 'rc.2', + isPrerelease: true, + }); +}); + +test('parses non-rc prerelease tags', () => { + assert.deepEqual(parseReleaseTag('v1.5.0-nightly.20260329.1'), { + tag: 'v1.5.0-nightly.20260329.1', + baseVersion: '1.5.0', + prerelease: 'nightly.20260329.1', + isPrerelease: true, + }); +}); + +test('rejects legacy rc tags without a dot-separated numeric identifier', () => { + assert.throws( + () => parseReleaseTag('v1.5.0-rc2'), + /Invalid RC tag format: v1\.5\.0-rc2\. Use v1\.5\.0-rc\.2 instead\./u, + ); +}); + +test('cli prints release tag metadata for workflows', () => { + const result = spawnSync(process.execPath, [scriptPath, '--tag', 'v1.5.0-rc.2'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, 'base_version=1.5.0\nis_prerelease=true\nprerelease=rc.2\n'); +}); + +test('cli fails with a canonical rc correction for legacy rc tags', () => { + const result = spawnSync(process.execPath, [scriptPath, '--tag', 'v1.5.0-rc2'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(result.status, 1); + assert.match( + result.stderr, + /Invalid RC tag format: v1\.5\.0-rc2\. Use v1\.5\.0-rc\.2 instead\./u, + ); +});