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
23 changes: 23 additions & 0 deletions .github/actions/setup-snyk/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
92 changes: 92 additions & 0 deletions .github/actions/wait-for-successful-branch-ci/action.yml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 25 additions & 10 deletions .github/workflows/ci-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ concurrency:
group: ci-verify-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
permissions: {}

jobs:
zizmor:
Expand Down Expand Up @@ -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
Expand All @@ -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}"
Expand Down
113 changes: 38 additions & 75 deletions .github/workflows/release-cut.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}"
Expand Down
Loading
Loading