Skip to content
Draft
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
183 changes: 119 additions & 64 deletions .github/workflows/next-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,86 +23,141 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Use the GitHub API to fetch changed files
files=$(gh pr view ${{ github.event.pull_request.number }} --json files -q '.files[].path')

# Sanitize to avoid code injection
sanitized_files=$(echo "$files" | sed 's/[^a-zA-Z0-9._/-]/_/g')

# Store the sanitized list of files in a temporary file to avoid env variable issues
echo "$sanitized_files" > modified_files.txt

- name: Fetch PR message
# Use the GitHub API to fetch the list of changed files, one per
# line. Stored as a file so the verify step can read it without
# passing PR-controlled strings through a shell command.
gh pr view ${{ github.event.pull_request.number }} --json files -q '.files[].path' > modified_files.txt
echo "Changed files:"
cat modified_files.txt

- name: Fetch PR description
id: pr-message
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Use the GitHub API to fetch the PR message
pr_message=$(gh pr view ${{ github.event.pull_request.number }} --json body -q '.body')

# Sanitize the PR message to avoid code injection, keeping the equal sign
sanitized_pr_message=$(echo "$pr_message" | sed 's/[^a-zA-Z0-9._/-=]/_/g')

# Store the sanitized PR message
echo "$sanitized_pr_message" > pr_message.txt

- name: Verify NEXT_CHANGELOG.md was modified or PR message contains NO_CHANGELOG=true
# Fetch the PR body; the verify step reads this as a literal string
# in Python, so shell escape rules never apply to its content.
gh pr view ${{ github.event.pull_request.number }} --json body -q '.body' > pr_body.txt

- name: Verify NEXT_CHANGELOG.md was modified for each touched package
run: |
# Read the sanitized files and PR message from the temporary files
modified_files=$(cat modified_files.txt)
pr_message=$(cat pr_message.txt)

# Check if NEXT_CHANGELOG.md exists in the list of changed files
echo "Changed files: $modified_files"
if ! echo "$modified_files" | grep -q "NEXT_CHANGELOG.md"; then
echo "NEXT_CHANGELOG.md not modified."

# Check if PR message contains NO_CHANGELOG=true
if echo "$pr_message" | grep -q "NO_CHANGELOG=true"; then
echo "NO_CHANGELOG=true found in PR message. Skipping changelog check."
exit 0
else
echo "WARNING: file NEXT_CHANGELOG.md not changed. If this is expected, add NO_CHANGELOG=true to the PR message."
exit 1
fi
fi
python3 <<'PYEOF'
import os
import re
import sys

# Read inputs written by the previous steps. Reading as literal
# strings keeps PR-controlled content safely inert.
modified = {l for l in open('modified_files.txt').read().splitlines() if l}
pr_body = open('pr_body.txt').read()

# Parse NO_CHANGELOG directive from the PR body.
# NO_CHANGELOG=true -> skip every package.
# NO_CHANGELOG=<pkg1>,<pkg2>,... -> skip only the listed packages.
skip_all = False
skip_packages = set()
m = re.search(r'NO_CHANGELOG=(\S+)', pr_body)
if m:
val = m.group(1).strip()
if val == 'true':
skip_all = True
else:
skip_packages = {p.strip().rstrip('/') for p in val.split(',') if p.strip()}

if skip_all:
print("NO_CHANGELOG=true found in PR body. Skipping check.")
sys.exit(0)

# Discover packages the same way tagging.py does: any directory that
# contains a .package.json is a release unit with its own changelog.
packages = []
for dirpath, dirnames, filenames in os.walk('.'):
dirnames[:] = [d for d in dirnames if d not in ('.git', 'node_modules', 'dist')]
if '.package.json' in filenames:
rel = os.path.relpath(dirpath, '.')
packages.append('' if rel == '.' else rel)

# Sub-package prefixes so a root-level package doesn't double-count
# files that live under a nested package.
sub_prefixes = tuple(p + '/' for p in packages if p)

- name: Comment on PR with instructions if needed
if: failure() # This step will only run if the previous step fails (i.e., if NEXT_CHANGELOG.md was not modified and NO_CHANGELOG=true was not in the PR message)
# For each package with modified files (excluding its own bookkeeping
# files), require that its NEXT_CHANGELOG.md was also modified.
missing = []
for pkg in packages:
prefix = pkg + '/' if pkg else ''
next_changelog = prefix + 'NEXT_CHANGELOG.md'
changelog = prefix + 'CHANGELOG.md'
package_file = prefix + '.package.json'
in_pkg = [
f for f in modified
if (not pkg or f.startswith(prefix))
and f not in (changelog, package_file)
]
if not pkg:
in_pkg = [f for f in in_pkg if not f.startswith(sub_prefixes)]
if not in_pkg:
continue
if next_changelog not in modified:
missing.append((pkg or '.', next_changelog))

# Honor per-package NO_CHANGELOG overrides.
missing = [(pkg, chg) for pkg, chg in missing if pkg not in skip_packages]

if missing:
# Build the PR comment body and also emit it to the workflow log
# so failures are debuggable without clicking into the PR.
lines = [
"<!-- NEXT_CHANGELOG_INSTRUCTIONS -->",
"One or more packages were modified without updating their `NEXT_CHANGELOG.md`:",
"",
]
for pkg, chg in missing:
lines.append(f"- `{pkg}` — expected a change to `{chg}`")
lines.append("")
lines.append("If this is intentional, add one of the following to the PR description and rerun this job:")
lines.append("")
lines.append("- `NO_CHANGELOG=true` — skip the check for every package.")
suggested = ",".join(pkg for pkg, _ in missing)
lines.append(f"- `NO_CHANGELOG={suggested}` — skip only the packages listed above.")
body = "\n".join(lines) + "\n"
with open('pr_comment.md', 'w') as f:
f.write(body)
sys.stdout.write(body)
sys.exit(1)

print("All modified packages have NEXT_CHANGELOG.md entries.")
PYEOF

- name: Comment on PR with the list of missing packages
if: failure() # Only runs if the verify step failed.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Check if a comment exists with the instructions
# Replace any previous instructions comment so the list reflects the
# current PR state instead of accumulating stale entries.
previous_comment_ids=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--jq '.[] | select(.body | startswith("<!-- NEXT_CHANGELOG_INSTRUCTIONS -->")) | .id')
echo "Previous comment IDs: $previous_comment_ids"

# If no previous comment exists, add one with instructions
if [ -z "$previous_comment_ids" ]; then
echo "Adding instructions comment."
gh pr comment ${{ github.event.pull_request.number }} --body \
"<!-- NEXT_CHANGELOG_INSTRUCTIONS -->
Please ensure that the NEXT_CHANGELOG.md file is updated with any relevant changes.
If this is not necessary for your PR, please include the following in your PR description:
NO_CHANGELOG=true
and rerun the job."
fi
for id in $previous_comment_ids; do
gh api "repos/${{ github.repository }}/issues/comments/$id" --method DELETE
done
gh pr comment ${{ github.event.pull_request.number }} --body-file pr_comment.md

- name: Delete instructions comment on success
if: success() # This step will only run if the previous check passed (i.e., if NEXT_CHANGELOG.md was modified or NO_CHANGELOG=true is in the PR message)
if: success() # Only runs if the verify step passed.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Check if there is a previous instructions comment
# Clean up any previous instructions comment now that the check is
# green.
previous_comment_ids=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--jq '.[] | select(.body | startswith("<!-- NEXT_CHANGELOG_INSTRUCTIONS -->")) | .id')

# If a comment exists, delete it
if [ -n "$previous_comment_ids" ]; then
echo "Deleting previous instructions comment."
for comment_id in $previous_comment_ids; do
gh api "repos/${{ github.repository }}/issues/comments/$comment_id" --method DELETE
done
else
echo "No instructions comment found to delete."
fi
if [ -n "$previous_comment_ids" ]; then
echo "Deleting previous instructions comments: $previous_comment_ids"
for id in $previous_comment_ids; do
gh api "repos/${{ github.repository }}/issues/comments/$id" --method DELETE
done
else
echo "No instructions comment found to delete."
fi
115 changes: 115 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Prepare Release

on:
push:
# Per-package tags produced by tagging.py, e.g. "settings/v0.2.0".
# Each tag push fires an independent prepare-release run, producing one
# tarball ready for release.
tags:
- "*/v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to prepare (e.g. settings/v0.2.0)"
required: true
type: string

concurrency:
group: prepare-release-${{ github.ref_name || inputs.tag }}
cancel-in-progress: false

permissions:
contents: read
id-token: write

jobs:
prepare:
name: Prepare ${{ github.ref_name || inputs.tag }}
runs-on:
group: databricks-protected-runner-group
labels: linux-ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Fetch tags + full history so git for-each-ref can read the
# annotated tag body (where tagging.py stores release notes).
fetch-depth: 0
ref: ${{ inputs.tag || github.ref }}

- name: Parse tag into package and version
id: parse
run: |
tag="${{ inputs.tag || github.ref_name }}"
package="${tag%/v*}"
version="${tag##*/v}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "package=$package" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Parsed tag=$tag package=$package version=$version"

- name: Validate package directory exists
run: |
if [ ! -f "packages/${{ steps.parse.outputs.package }}/package.json" ]; then
echo "::error::No package.json at packages/${{ steps.parse.outputs.package }}/"
echo "Tag ${{ steps.parse.outputs.tag }} does not correspond to a workspace package."
exit 1
fi

- name: Setup JFrog CLI
uses: jfrog/setup-jfrog-cli@279b1f629f43dd5bc658d8361ac4802a7ef8d2d5 # v4.9.1
env:
JF_URL: https://databricks.jfrog.io
with:
oidc-provider-name: github-actions

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
cache: "npm"

- name: Configure npm for JFrog
run: jf npmc --repo-resolve=db-npm

- name: Install dependencies
run: jf npm ci

- name: Build workspace
run: npm run build

- name: Pack package tarball
id: pack
working-directory: packages/${{ steps.parse.outputs.package }}
run: |
# npm pack emits the filename on stdout; capture it so release can
# match it without guessing the scope's underscore-encoding.
tarball=$(npm pack --silent)
echo "tarball=packages/${{ steps.parse.outputs.package }}/${tarball}" >> "$GITHUB_OUTPUT"
echo "Packed $tarball"

- name: Stage release artifacts
id: stage
run: |
mkdir -p release-artifacts
cp "${{ steps.pack.outputs.tarball }}" release-artifacts/
echo "${{ steps.parse.outputs.version }}" > release-artifacts/VERSION
echo "${{ steps.parse.outputs.package }}" > release-artifacts/PACKAGE
echo "${{ steps.parse.outputs.tag }}" > release-artifacts/TAG
# tagging.py embeds the NEXT_CHANGELOG section into the annotated
# tag body; extract it verbatim for release to attach to the GitHub
# release.
git for-each-ref --format='%(body)' "refs/tags/${{ steps.parse.outputs.tag }}" > release-artifacts/changelog-diff.md
(cd release-artifacts && sha256sum *.tgz VERSION PACKAGE TAG changelog-diff.md > SHA256SUMS)
echo "Artifacts staged:"
ls -la release-artifacts/

- name: Upload release artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: sdk-js-release-${{ github.run_number }}
retention-days: 7
if-no-files-found: error
path: release-artifacts/
Loading