diff --git a/.github/workflows/release-build-java.yml b/.github/workflows/release-build-java.yml index 90ad6d3..f00abd9 100644 --- a/.github/workflows/release-build-java.yml +++ b/.github/workflows/release-build-java.yml @@ -4,9 +4,21 @@ on: push: tags: - 'java-v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: jobs: + validate-ref: + runs-on: ubuntu-22.04 + steps: + - name: Ensure ref is a release tag + run: | + if ! [[ "${GITHUB_REF_NAME}" =~ ^java-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::This workflow must be dispatched from a release tag (java-vX.Y.Z), not '${GITHUB_REF_NAME}'" + exit 1 + fi + build-release-artifacts: + needs: validate-ref uses: ./.github/workflows/build-artifacts.yml with: BUILD_COMMAND: make build-java diff --git a/.github/workflows/release-build-nodejs.yml b/.github/workflows/release-build-nodejs.yml index 4e76fbd..724c936 100644 --- a/.github/workflows/release-build-nodejs.yml +++ b/.github/workflows/release-build-nodejs.yml @@ -4,9 +4,21 @@ on: push: tags: - 'nodejs-v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: jobs: + validate-ref: + runs-on: ubuntu-22.04 + steps: + - name: Ensure ref is a release tag + run: | + if ! [[ "${GITHUB_REF_NAME}" =~ ^nodejs-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::This workflow must be dispatched from a release tag (nodejs-vX.Y.Z), not '${GITHUB_REF_NAME}'" + exit 1 + fi + build-release-artifacts: + needs: validate-ref uses: ./.github/workflows/build-artifacts.yml with: BUILD_COMMAND: make build-nodejs diff --git a/.github/workflows/release-build-python.yml b/.github/workflows/release-build-python.yml index 2aa9f8e..bcda08d 100644 --- a/.github/workflows/release-build-python.yml +++ b/.github/workflows/release-build-python.yml @@ -4,9 +4,21 @@ on: push: tags: - 'python-v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: jobs: + validate-ref: + runs-on: ubuntu-22.04 + steps: + - name: Ensure ref is a release tag + run: | + if ! [[ "${GITHUB_REF_NAME}" =~ ^python-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::This workflow must be dispatched from a release tag (python-vX.Y.Z), not '${GITHUB_REF_NAME}'" + exit 1 + fi + build-release-artifacts: + needs: validate-ref uses: ./.github/workflows/build-artifacts.yml with: BUILD_COMMAND: make build-python diff --git a/.github/workflows/release-check-upstream.yml b/.github/workflows/release-check-upstream.yml new file mode 100644 index 0000000..be34f15 --- /dev/null +++ b/.github/workflows/release-check-upstream.yml @@ -0,0 +1,161 @@ +name: Check Upstream Releases + +on: + schedule: + - cron: '7 9 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: "If true, detect releases but don't trigger prepare workflow" + required: false + type: boolean + default: true + language: + description: "Only check this language (leave empty to check all)" + required: false + type: choice + options: + - '' + - java + - nodejs + - python + +permissions: + contents: read + actions: write + pull-requests: read + +jobs: + check: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Check for new upstream releases + env: + GH_TOKEN: ${{ github.token }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + FILTER_LANGUAGE: ${{ inputs.language || '' }} + run: | + set -euo pipefail + + UPSTREAM="https://github.com/open-telemetry/opentelemetry-lambda.git" + + declare -A TAG_PREFIX=( + [java]="layer-javaagent" + [nodejs]="layer-nodejs" + [python]="layer-python" + ) + + semver_gt() { + local lhs="$1" rhs="$2" + if [[ "${lhs}" == "${rhs}" ]]; then + return 1 + fi + local IFS='.' + read -ra L <<< "${lhs}" + read -ra R <<< "${rhs}" + for i in 0 1 2; do + if (( ${L[$i]:-0} > ${R[$i]:-0} )); then + return 0 + elif (( ${L[$i]:-0} < ${R[$i]:-0} )); then + return 1 + fi + done + return 1 + } + + minor_bump() { + local ver="$1" + local IFS='.' + read -ra parts <<< "${ver}" + echo "${parts[0]}.$(( parts[1] + 1 )).0" + } + + latest_upstream_tag() { + local prefix="$1" + git ls-remote --tags "${UPSTREAM}" "refs/tags/${prefix}/*" \ + | sed 's|.*refs/tags/||' \ + | grep -v '\^{}' \ + | sort -t/ -k2 -V \ + | tail -1 + } + + TRIGGERED=() + + LANGUAGES=(java nodejs python) + if [[ -n "${FILTER_LANGUAGE}" ]]; then + LANGUAGES=("${FILTER_LANGUAGE}") + fi + + for LANGUAGE in "${LANGUAGES[@]}"; do + PREFIX="${TAG_PREFIX[${LANGUAGE}]}" + + echo "--- Checking ${LANGUAGE} (prefix: ${PREFIX}) ---" + + LATEST_UPSTREAM=$(latest_upstream_tag "${PREFIX}") + if [[ -z "${LATEST_UPSTREAM}" ]]; then + echo "WARNING: No upstream tags found for ${PREFIX}" + continue + fi + echo "Latest upstream: ${LATEST_UPSTREAM}" + + VERSION_FILE="${LANGUAGE}/version.txt" + CURRENT_VER=$(grep '^current_version=' "${VERSION_FILE}" | cut -d= -f2-) + LAST_PROCESSED=$(grep '^upstream_release_tag=' "${VERSION_FILE}" | cut -d= -f2-) + echo "Current version: ${CURRENT_VER}" + echo "Last processed upstream: ${LAST_PROCESSED}" + + if [[ -z "${CURRENT_VER}" || -z "${LAST_PROCESSED}" ]]; then + echo "ERROR: could not read named values from ${VERSION_FILE}" + continue + fi + + if [[ "${LATEST_UPSTREAM}" == "${LAST_PROCESSED}" ]]; then + echo "No new release for ${LANGUAGE}" + continue + fi + + UPSTREAM_VER="${LATEST_UPSTREAM#"${PREFIX}/"}" + PROCESSED_VER="${LAST_PROCESSED#"${PREFIX}/"}" + if ! semver_gt "${UPSTREAM_VER}" "${PROCESSED_VER}"; then + echo "Upstream ${UPSTREAM_VER} is not newer than ${PROCESSED_VER}" + continue + fi + + echo "New release detected: ${LATEST_UPSTREAM} > ${LAST_PROCESSED}" + + EXISTING_PR=$(gh pr list \ + --state open \ + --base main \ + --json number,headRefName \ + --jq "map(select(.headRefName | startswith(\"prepare-${LANGUAGE}-v\")))[0].number // empty") + if [[ -n "${EXISTING_PR}" ]]; then + echo "Skipping ${LANGUAGE}: open prepare PR #${EXISTING_PR} exists" + continue + fi + + NEXT_VER=$(minor_bump "${CURRENT_VER}") + echo "Version bump: ${CURRENT_VER} -> ${NEXT_VER}" + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "[DRY RUN] Would trigger release-prepare for ${LANGUAGE} v${NEXT_VER} with tag ${LATEST_UPSTREAM}" + continue + fi + + echo "Triggering release-prepare for ${LANGUAGE} v${NEXT_VER}" + gh workflow run release-prepare.yml \ + -f language="${LANGUAGE}" \ + -f version="${NEXT_VER}" \ + -f otel_lambda_tag="${LATEST_UPSTREAM}" + TRIGGERED+=("${LANGUAGE}-v${NEXT_VER}") + done + + if [[ ${#TRIGGERED[@]} -gt 0 ]]; then + echo "" + echo "=== Triggered releases ===" + printf ' %s\n' "${TRIGGERED[@]}" + else + echo "" + echo "=== No release workflows triggered ===" + fi diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml new file mode 100644 index 0000000..38c59e7 --- /dev/null +++ b/.github/workflows/release-finalize.yml @@ -0,0 +1,80 @@ +name: Release Finalize + +on: + workflow_dispatch: + inputs: + tag: + description: "Pre-release tag to finalize" + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-finalize-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + finalize: + runs-on: ubuntu-22.04 + steps: + - name: Extract release details + id: parse + env: + TAG: ${{ inputs.tag }} + run: | + if ! [[ "${TAG}" =~ ^(java|nodejs|python)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::Unexpected release tag: ${TAG}" + exit 1 + fi + + LANGUAGE="${BASH_REMATCH[1]}" + VERSION="${BASH_REMATCH[2]}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "language=${LANGUAGE}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "release_branch=release-${TAG}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email \ + "github-actions[bot]@users.noreply.github.com" + + - name: Create release branch + id: branch + env: + RELEASE_BRANCH: ${{ steps.parse.outputs.release_branch }} + run: | + if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}"; then + echo "Release branch ${RELEASE_BRANCH} already exists" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + git checkout -b "${RELEASE_BRANCH}" + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Update language README title + if: steps.branch.outputs.exists != 'true' + env: + TAG: ${{ steps.parse.outputs.tag }} + LANGUAGE: ${{ steps.parse.outputs.language }} + VERSION: ${{ steps.parse.outputs.version }} + run: ./ci/release-finalize.sh + + - name: Push release branch + if: steps.branch.outputs.exists != 'true' + env: + TAG: ${{ steps.parse.outputs.tag }} + LANGUAGE: ${{ steps.parse.outputs.language }} + RELEASE_BRANCH: ${{ steps.parse.outputs.release_branch }} + run: | + git add "${LANGUAGE}/README.md" + git commit -m "docs: update ${LANGUAGE} README for ${TAG}" + git push origin "${RELEASE_BRANCH}" diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..b88d92d --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,182 @@ +name: Release Prepare + +on: + workflow_dispatch: + inputs: + language: + description: "Language to release" + required: true + type: choice + options: + - java + - nodejs + - python + version: + description: "Version (e.g. 1.41.0)" + required: true + type: string + otel_lambda_tag: + description: >- + opentelemetry-lambda release tag to pin the submodule to + (e.g. layer-python/0.19.0, layer-nodejs/0.21.0, + layer-javaagent/0.19.0) + required: true + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-prepare-${{ inputs.language }} + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-22.04 + env: + LANGUAGE: ${{ inputs.language }} + VERSION: ${{ inputs.version }} + OTEL_LAMBDA_TAG: ${{ inputs.otel_lambda_tag }} + BRANCH: prepare-${{ inputs.language }}-v${{ inputs.version }} + steps: + - name: Validate inputs + run: | + if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version must be semver without v prefix (e.g. 1.41.0)" + exit 1 + fi + + case "${LANGUAGE}" in + java) EXPECTED_PREFIX="layer-javaagent/" ;; + nodejs) EXPECTED_PREFIX="layer-nodejs/" ;; + python) EXPECTED_PREFIX="layer-python/" ;; + *) + echo "::error::Unsupported language: ${LANGUAGE}" + exit 1 + ;; + esac + + if [[ "${OTEL_LAMBDA_TAG}" != "${EXPECTED_PREFIX}"* ]]; then + echo "::error::${LANGUAGE} requires an upstream tag starting with ${EXPECTED_PREFIX}" + exit 1 + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Verify upstream release tag exists + run: | + if ! git ls-remote --exit-code --tags \ + https://github.com/open-telemetry/opentelemetry-lambda.git \ + "refs/tags/${OTEL_LAMBDA_TAG}" >/dev/null; then + echo "::error::Upstream tag ${OTEL_LAMBDA_TAG} does not exist" + exit 1 + fi + + - name: Check release tag and branch do not already exist + run: | + TAG="${LANGUAGE}-v${VERSION}" + if git tag --list "${TAG}" | grep -q .; then + echo "::error::Tag ${TAG} already exists" + exit 1 + fi + if git ls-remote --heads origin "${BRANCH}" | grep -q .; then + echo "::error::Branch ${BRANCH} already exists on remote" + exit 1 + fi + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email \ + "github-actions[bot]@users.noreply.github.com" + + - name: Create branch + run: git checkout -b "${BRANCH}" + + - name: Pin opentelemetry-lambda release + id: submodule + run: | + git -C opentelemetry-lambda fetch --tags origin + git -C opentelemetry-lambda checkout --detach "${OTEL_LAMBDA_TAG}" + echo "commit=$(git -C opentelemetry-lambda rev-parse HEAD)" \ + >> "$GITHUB_OUTPUT" + + - name: Run prepare script + run: ./ci/release-prepare.sh + + - name: Commit changes + run: | + git add \ + "changelog/${LANGUAGE}-v${VERSION}.md" \ + "${LANGUAGE}/version.txt" \ + "${LANGUAGE}/layer-data.sh" \ + "${LANGUAGE}/sample-apps/template.yaml" \ + "${LANGUAGE}/README.md" \ + README.md \ + opentelemetry-lambda + COMMIT_MSG="feat: prepare release ${LANGUAGE} v${VERSION}" + git commit -m "${COMMIT_MSG}" + + - name: Push branch + run: git push origin "${BRANCH}" + + - name: Ensure release label exists + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create release \ + --color "0075ca" \ + --description "Release PR" \ + 2>/dev/null || true + + - name: Create pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + UPSTREAM_REPO="https://github.com/open-telemetry/opentelemetry-lambda" + gh pr create \ + --title "feat: prepare release ${LANGUAGE} v${VERSION}" \ + --body "$(cat <
- + github.event.pull_request.merged == true && + (startsWith( + github.event.pull_request.head.ref, + 'prepare-java-v') || + startsWith( + github.event.pull_request.head.ref, + 'prepare-nodejs-v') || + startsWith( + github.event.pull_request.head.ref, + 'prepare-python-v')) + runs-on: ubuntu-22.04 + steps: + - name: Extract release details + id: parse + env: + BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + TAG="${BRANCH#prepare-}" + if ! [[ "${TAG}" =~ ^(java|nodejs|python)-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Unexpected release branch: ${BRANCH}" + exit 1 + fi + LANGUAGE="${BASH_REMATCH[1]}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "language=${LANGUAGE}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email \ + "github-actions[bot]@users.noreply.github.com" + + - name: Check release state + id: state + env: + TAG: ${{ steps.parse.outputs.tag }} + run: | + if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists; skipping changelog assembly" + echo "tag_exists=true" >> "$GITHUB_OUTPUT" + else + echo "tag_exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Assemble changelog fragment + if: steps.state.outputs.tag_exists != 'true' + env: + TAG: ${{ steps.parse.outputs.tag }} + run: | + FRAGMENT="changelog/${TAG}.md" + if [[ ! -f "${FRAGMENT}" ]]; then + echo "::error::Missing changelog fragment ${FRAGMENT}" + exit 1 + fi + if ! grep -q '^All notable changes' CHANGELOG.md; then + echo "::error::Could not find changelog insertion anchor" + exit 1 + fi + + sed -i "/^All notable changes/r ${FRAGMENT}" CHANGELOG.md + rm "${FRAGMENT}" + + - name: Commit changelog and create tag + if: steps.state.outputs.tag_exists != 'true' + env: + TAG: ${{ steps.parse.outputs.tag }} + run: | + git add CHANGELOG.md "changelog/${TAG}.md" + git commit -m "release: ${TAG}" + git tag -m "${TAG}" "${TAG}" + git push --atomic origin main "${TAG}" + + - name: Dispatch release build + env: + GH_TOKEN: ${{ github.token }} + LANGUAGE: ${{ steps.parse.outputs.language }} + TAG: ${{ steps.parse.outputs.tag }} + run: gh workflow run "release-build-${LANGUAGE}.yml" --ref "${TAG}" diff --git a/changelog/.gitkeep b/changelog/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ci/release-finalize.sh b/ci/release-finalize.sh new file mode 100755 index 0000000..74e5df7 --- /dev/null +++ b/ci/release-finalize.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -euo pipefail + +: "${LANGUAGE:?LANGUAGE is required (java, nodejs, or python)}" +: "${VERSION:?VERSION is required (e.g. 1.41.0)}" + +if [[ ! "${LANGUAGE}" =~ ^(java|nodejs|python)$ ]]; then + echo "ERROR: Unsupported language: ${LANGUAGE}" >&2 + exit 1 +fi +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: Invalid version: ${VERSION}" >&2 + exit 1 +fi + +README="${LANGUAGE}/README.md" + +python3 - "${README}" "${VERSION}" <<'PYEOF' +import re +import sys + +readme_path = sys.argv[1] +version = sys.argv[2] + +with open(readme_path) as f: + content = f.read() + +title_pattern = re.compile( + r"^(# .*?)(?:unreleased version|v\d+\.\d+\.\d+)(.*)$", + re.MULTILINE, +) +if not title_pattern.search(content): + print(f"ERROR: release version not found in title of {readme_path}", + file=sys.stderr) + sys.exit(1) + +content = title_pattern.sub(rf"\g<1>v{version}\g<2>", content, count=1) + +with open(readme_path, "w") as f: + f.write(content) + +print(f"Updated {readme_path} title to v{version}") +PYEOF diff --git a/ci/release-prepare.sh b/ci/release-prepare.sh new file mode 100755 index 0000000..0dca927 --- /dev/null +++ b/ci/release-prepare.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +set -euo pipefail + +: "${LANGUAGE:?LANGUAGE is required (java, nodejs, or python)}" +: "${VERSION:?VERSION is required (e.g. 1.41.0)}" +: "${OTEL_LAMBDA_TAG:?OTEL_LAMBDA_TAG is required (e.g. layer-python/0.19.0)}" + +VERSION_DASHED="v${VERSION//./-}" +TAG="${LANGUAGE}-v${VERSION}" +RELEASE_BRANCH="release-${TAG}" +DATE="$(date +%Y-%m-%d)" + +LAYER_DATA="${LANGUAGE}/layer-data.sh" +TEMPLATE="${LANGUAGE}/sample-apps/template.yaml" +VERSION_FILE="${LANGUAGE}/version.txt" +LANGUAGE_README="${LANGUAGE}/README.md" + +cleanup() { find . -maxdepth 3 -name '*.bak' -delete; } +trap cleanup EXIT + +OLD_VERSION_DASHED=$(grep '^VERSION=' "${LAYER_DATA}" | cut -d= -f2) +if [[ -z "${OLD_VERSION_DASHED}" ]]; then + echo "ERROR: could not extract VERSION from ${LAYER_DATA}" >&2 + exit 1 +fi + +# --- Create changelog fragment (avoids CHANGELOG.md merge conflicts) --- + +mkdir -p changelog +cat > "changelog/${TAG}.md" <