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
196 changes: 148 additions & 48 deletions .github/workflows/prepare-release.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
# Prepare Release Workflow
# This workflow automates the release preparation process:
# 1. Updates package versions in all package.json files
# 2. Generates changelog from git diff between main and last release tag
# 3. Creates a release branch and tag
# After the workflow completes, manually create a PR from the release branch to main.
# This workflow automates per-package release preparation. Choose which package to release
# with the required `package` input; the workflow then, for THAT package only:
# 1. Bumps the selected package's version in its own package.json
# 2. Generates a changelog from git history since the package's last release tag
# 3. Updates the package's changelog file and creates a release branch + package-scoped tag
# Packages are versioned and tagged independently (tag prefixes: v, azuremanaged-v, durable-functions-v).
# After the workflow completes, manually open a PR from the release branch to main, then publish
# from the package directory using the npm command shown in the run summary (mind the dist-tag).

name: Prepare Release

on:
workflow_dispatch:
inputs:
package:
description: 'Which package to release'
required: true
type: choice
options:
- durabletask-js
- durabletask-js-azuremanaged
- azure-functions-durable
version:
description: 'Release version (e.g., 0.2.0-beta.1). Leave empty to auto-increment patch version.'
required: false
Expand All @@ -32,10 +43,45 @@ jobs:
with:
node-version: '22'

- name: Resolve package metadata
id: resolve-package
run: |
PACKAGE="${{ github.event.inputs.package }}"
case "$PACKAGE" in
durabletask-js)
PKG_DIR="packages/durabletask-js"
NPM_NAME="@microsoft/durabletask-js"
TAG_PREFIX="v"
CHANGELOG_PATH="CHANGELOG.md"
;;
durabletask-js-azuremanaged)
PKG_DIR="packages/durabletask-js-azuremanaged"
NPM_NAME="@microsoft/durabletask-js-azuremanaged"
TAG_PREFIX="azuremanaged-v"
CHANGELOG_PATH="CHANGELOG.md"
;;
azure-functions-durable)
PKG_DIR="packages/azure-functions-durable"
NPM_NAME="durable-functions"
TAG_PREFIX="durable-functions-v"
CHANGELOG_PATH="packages/azure-functions-durable/CHANGELOG.md"
;;
*)
echo "::error::Unknown package '$PACKAGE'. Expected one of: durabletask-js, durabletask-js-azuremanaged, azure-functions-durable."
exit 1
;;
esac
echo "pkg_dir=$PKG_DIR" >> $GITHUB_OUTPUT
echo "npm_name=$NPM_NAME" >> $GITHUB_OUTPUT
echo "tag_prefix=$TAG_PREFIX" >> $GITHUB_OUTPUT
echo "changelog_path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
echo "Releasing '$PACKAGE': dir=$PKG_DIR npm=$NPM_NAME tag_prefix=$TAG_PREFIX changelog=$CHANGELOG_PATH"

- name: Get current version
id: get-current-version
run: |
CURRENT_VERSION=$(node -p "require('./packages/durabletask-js/package.json').version")
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
CURRENT_VERSION=$(node -p "require('./${PKG_DIR}/package.json').version")
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT_VERSION"

Expand Down Expand Up @@ -77,10 +123,17 @@ jobs:
id: get-latest-tag
run: |
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
# Get the latest tag that looks like a version (v*), excluding the
# tag being created so that re-runs don't use it as the baseline.
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | \
grep -v "^v${NEW_VERSION}$" | head -n 1)
TAG_PREFIX="${{ steps.resolve-package.outputs.tag_prefix }}"
# Get the latest tag for THIS package (its tag prefix), excluding the tag being
# created so that re-runs don't use it as the baseline. The legacy core prefix "v"
# still matches historical tags like v0.3.0 (intended, backward compatible) and never
# matches the "azuremanaged-v"/"durable-functions-v" namespaces (they start with a/d, not v).
# Without versionsort.suffix=-, git ranks a prerelease (e.g. X-beta.1) ABOVE its own GA
# (X), so the release AFTER GA (e.g. X.0.1) would resolve LATEST_TAG to the prerelease and
# re-list every commit already shipped in GA. Treating "-" as the prerelease separator
# orders X-beta.1 below X.
LATEST_TAG=$(git -c versionsort.suffix=- tag -l "${TAG_PREFIX}*" --sort=-v:refname | \
grep -v "^${TAG_PREFIX}${NEW_VERSION}$" | head -n 1)
if [ -z "$LATEST_TAG" ]; then
echo "No previous release tag found, using initial commit"
LATEST_TAG=$(git rev-list --max-parents=0 HEAD)
Expand All @@ -94,12 +147,17 @@ jobs:
LATEST_TAG="${{ steps.get-latest-tag.outputs.latest_tag }}"
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"

echo "Generating changelog for changes between $LATEST_TAG and HEAD..."
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
echo "Generating changelog for changes between $LATEST_TAG and HEAD (scoped to $PKG_DIR)..."

# Get commits between last tag and HEAD.
# Get commits between last tag and HEAD, scoped to the released package's directory via the
# "-- $PKG_DIR" path filter so a release only lists commits that touched that package.
# Without it every package's commits are listed -- and since a package with no release tag
# yet falls back to the repo's initial commit, the first release would dump the ENTIRE repo
# history into that package's changelog.
# GitHub squash-merges put the PR number in parentheses, e.g. "Some change (#123)".
# We convert "(#N)" to a markdown link.
CHANGELOG_CONTENT=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" --no-merges | \
CHANGELOG_CONTENT=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" --no-merges -- "$PKG_DIR" | \
sed 's/(#\([0-9]*\))/([#\1](https:\/\/github.com\/microsoft\/durabletask-js\/pull\/\1))/' | \
sed 's/^/- /' | \
grep -v '^- $' || echo "")
Expand All @@ -111,84 +169,126 @@ jobs:
echo "Generated changelog:"
cat /tmp/changelog_content.txt

- name: Update package versions
- name: Update package version
run: |
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"

echo "Updating packages to version $NEW_VERSION..."
echo "Updating ${PKG_DIR} to version $NEW_VERSION..."

# Update durabletask-js package.json
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('packages/durabletask-js/package.json', 'utf8'));
const path = '${PKG_DIR}/package.json';
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
pkg.version = '$NEW_VERSION';
fs.writeFileSync('packages/durabletask-js/package.json', JSON.stringify(pkg, null, 2) + '\n');
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
"

# Update durabletask-js-azuremanaged package.json
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('packages/durabletask-js-azuremanaged/package.json', 'utf8'));
pkg.version = '$NEW_VERSION';
// Also update peer dependency to the new version
if (pkg.peerDependencies && pkg.peerDependencies['@microsoft/durabletask-js']) {
pkg.peerDependencies['@microsoft/durabletask-js'] = '>=$NEW_VERSION';
}
fs.writeFileSync('packages/durabletask-js-azuremanaged/package.json', JSON.stringify(pkg, null, 2) + '\n');
"

echo "Updated package.json files:"
grep '"version"' packages/durabletask-js/package.json
grep '"version"' packages/durabletask-js-azuremanaged/package.json
echo "Updated ${PKG_DIR}/package.json:"
grep '"version"' "${PKG_DIR}/package.json"

- name: Update CHANGELOG.md
- name: Update changelog
run: |
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
CHANGELOG_FILE="${{ steps.changelog-diff.outputs.changelog_file }}"
CHANGELOG_PATH="${{ steps.resolve-package.outputs.changelog_path }}"
RELEASE_DATE=$(date +%Y-%m-%d)
node scripts/update-changelog.js "$NEW_VERSION" "$RELEASE_DATE" "$CHANGELOG_FILE"
node scripts/update-changelog.js "$NEW_VERSION" "$RELEASE_DATE" "$CHANGELOG_FILE" "$CHANGELOG_PATH"

- name: Verify pinned core dependency is published on npm
if: ${{ github.event.inputs.package == 'azure-functions-durable' }}
run: |
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
PINNED=$(node -p "require('./${PKG_DIR}/package.json').dependencies['@microsoft/durabletask-js'] || ''")
if [ -z "$PINNED" ]; then
echo "::error::Could not read the @microsoft/durabletask-js pin from ${PKG_DIR}/package.json."
exit 1
fi
echo "durable-functions pins @microsoft/durabletask-js@${PINNED}; verifying it is published on public npm..."
# Query the public registry explicitly: durable-functions is installed from npmjs.org.
# The repo has NO committed .npmrc; instead root package.json's "preinstall" hook
# (scripts/preinstallNpmrc.js) generates a gitignored .npmrc via scripts/setupNpmrc.js
# that points npm at the azfunc Azure DevOps feed for @microsoft.com users and azfunc CI
# builds. Forcing --registry keeps this check on public npm regardless of any such
# generated (or machine-level) npm config, which would otherwise 401 on this query.
PUBLISHED=$(npm view "@microsoft/durabletask-js@${PINNED}" version --registry https://registry.npmjs.org/ 2>/dev/null || true)
if [ "$PUBLISHED" = "$PINNED" ]; then
echo "OK: @microsoft/durabletask-js@${PINNED} is published on npm."
else
echo "::error::durable-functions depends on @microsoft/durabletask-js@${PINNED}, but that exact version is not published on the public npm registry. Release @microsoft/durabletask-js@${PINNED} first, otherwise the published durable-functions package will have an uninstallable dependency."
exit 1
fi

- name: Create release branch and commit
id: create-branch
run: |
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
BRANCH_NAME="release/v${NEW_VERSION}"
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
NPM_NAME="${{ steps.resolve-package.outputs.npm_name }}"
TAG_PREFIX="${{ steps.resolve-package.outputs.tag_prefix }}"
CHANGELOG_PATH="${{ steps.resolve-package.outputs.changelog_path }}"
TAG_NAME="${TAG_PREFIX}${NEW_VERSION}"
BRANCH_NAME="release/${TAG_NAME}"

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

# Create and checkout release branch (idempotent)
git checkout -B "$BRANCH_NAME"

# Stage and commit changes
git add packages/durabletask-js/package.json
git add packages/durabletask-js-azuremanaged/package.json
git add CHANGELOG.md
git commit -m "Release v${NEW_VERSION}"
# Stage and commit changes (only the released package and its changelog)
git add "${PKG_DIR}/package.json"
git add "${CHANGELOG_PATH}"
git commit -m "Release ${NPM_NAME}@${NEW_VERSION}"

# Create release tag (idempotent)
git tag -f "v${NEW_VERSION}"
git tag -f "$TAG_NAME"

# Push branch and tag (force to handle re-runs)
git push -f origin "$BRANCH_NAME"
git push -f origin "v${NEW_VERSION}"
git push -f origin "$TAG_NAME"

echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "Created branch $BRANCH_NAME and tag v${NEW_VERSION}"
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
echo "Created branch $BRANCH_NAME and tag $TAG_NAME"

- name: Summary
run: |
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
NPM_NAME="${{ steps.resolve-package.outputs.npm_name }}"
BRANCH_NAME="${{ steps.create-branch.outputs.branch_name }}"
TAG_NAME="${{ steps.create-branch.outputs.tag_name }}"

# Derive the npm dist-tag from the version string. A prerelease (contains "-") must
# publish under a non-default tag so it does NOT move the "latest" dist-tag.
if [[ "$NEW_VERSION" == *-* ]]; then
PUBLISH_CMD="npm publish --registry https://registry.npmjs.org/ --tag preview"
else
PUBLISH_CMD="npm publish --registry https://registry.npmjs.org/"
fi

echo "## Release Preparation Complete! :rocket:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: v${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY
echo "- **Package**: ${NPM_NAME}" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY
echo "- **Branch**: ${BRANCH_NAME}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag**: v${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag**: ${TAG_NAME}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Next Step: Create a Pull Request" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Manually create a PR from **${BRANCH_NAME}** → **main** with title: **Release v${NEW_VERSION}**" >> $GITHUB_STEP_SUMMARY
echo "Manually create a PR from **${BRANCH_NAME}** → **main** with title: **Release ${NPM_NAME}@${NEW_VERSION}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "[Create PR](https://github.com/microsoft/durabletask-js/compare/main...${BRANCH_NAME}?expand=1&title=Release+${NPM_NAME}@${NEW_VERSION})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "[Create PR](https://github.com/microsoft/durabletask-js/compare/main...${BRANCH_NAME}?expand=1&title=Release+v${NEW_VERSION})" >> $GITHUB_STEP_SUMMARY
echo "### Then Publish" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "After the release PR is merged, publish from the package directory:" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "cd ${PKG_DIR}" >> $GITHUB_STEP_SUMMARY
echo "${PUBLISH_CMD}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
if [[ "$NEW_VERSION" == *-* ]]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "> :warning: This is a prerelease. Omitting \`--tag preview\` would move the \`latest\` dist-tag to a prerelease version." >> $GITHUB_STEP_SUMMARY
fi
60 changes: 52 additions & 8 deletions scripts/update-changelog.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,66 @@
#!/usr/bin/env node
// This script updates CHANGELOG.md with a new release section
// Usage: node update-changelog.js <version> <date> <changelog-file>
// This script updates a changelog file with a new release section.
// Usage: node update-changelog.js <version> <date> <changelog-file> [target-changelog]
// <changelog-file> file containing the generated changelog entries to insert
// [target-changelog] changelog to update in place (default: CHANGELOG.md)

const fs = require('fs');

const [,, version, releaseDate, changelogFile] = process.argv;
const [,, version, releaseDate, changelogFile, targetChangelog = 'CHANGELOG.md'] = process.argv;

if (!version || !releaseDate || !changelogFile) {
console.error('Usage: node update-changelog.js <version> <date> <changelog-file>');
console.error('Usage: node update-changelog.js <version> <date> <changelog-file> [target-changelog]');
process.exit(1);
}

const changelogContent = fs.readFileSync(changelogFile, 'utf8').trim();
let content = fs.readFileSync('CHANGELOG.md', 'utf8');
let content = fs.readFileSync(targetChangelog, 'utf8');

Comment thread
YunchuWang marked this conversation as resolved.
// One-time normalization for the legacy azure-functions-durable changelog. It ships as a
// placeholder skeleton ("# Changelog" + "## TBD" + a "Details to be finalized" bullet) instead of
// the "## Upcoming"/"## v*" structure this script expects. Left as-is, the first release would
// prepend new sections and strand that skeleton at the bottom, producing a mixed-format file.
// Match ONLY that exact pristine skeleton (CRLF/LF tolerant) and swap in the standard empty
// Upcoming scaffold; any real curated "## TBD" notes have a different shape and are left untouched.
const nonEmptyLines = content.split('\n').map((line) => line.replace(/\r$/, '').trim()).filter(Boolean);
const isLegacySkeleton =
nonEmptyLines.length === 3 &&
nonEmptyLines[0] === '# Changelog' &&
nonEmptyLines[1] === '## TBD' &&
nonEmptyLines[2] === '- Details to be finalized at release time.';
if (isLegacySkeleton) {
content = '## Upcoming\n\n### New\n\n### Fixes\n';
}

// Promote any curated notes from the current "## Upcoming" section into the release section.
// A subsection that is just an empty scaffold heading (e.g. "### New" with nothing under it) is
// dropped, so cutting a release with an untouched Upcoming section promotes nothing and produces
// byte-for-byte the same output as before.
let promoted = '';
const currentUpcoming = content.match(/## Upcoming[\s\S]*?(?=\n## v|$)/);
if (currentUpcoming) {
const body = currentUpcoming[0].replace(/^## Upcoming[^\n]*\n?/, '');
const kept = [];
for (const part of body.split(/(?=^### )/m)) {
const heading = part.match(/^### [^\n]*/);
if (!heading) {
// Lead-in body text before the first "### " subsection (e.g. a preview notice). Promote it
// too, ahead of the subsections; a whitespace-only scaffold gap promotes nothing.
const lead = part.trim();
if (lead) kept.push(lead);
continue;
}
const subContent = part.slice(heading[0].length).trim();
if (subContent) {
kept.push(`${heading[0].trim()}\n\n${subContent}`);
}
}
promoted = kept.join('\n\n');
}

const newSection = `## v${version} (${releaseDate})

### Changes
${promoted ? promoted + '\n\n' : ''}### Changes

${changelogContent}
`;
Expand All @@ -33,5 +77,5 @@ if (upcomingMatch) {
// Reset the Upcoming section to empty
content = content.replace(/## Upcoming[\s\S]*?(?=\n## v)/, '## Upcoming\n\n### New\n\n### Fixes\n\n');

fs.writeFileSync('CHANGELOG.md', content);
console.log('Updated CHANGELOG.md');
fs.writeFileSync(targetChangelog, content);
console.log(`Updated ${targetChangelog}`);
Loading