Skip to content

Commit fc4a7f7

Browse files
authored
ci(release): per-package prepare-release workflow + changelog script (#337)
1 parent 3ebaf33 commit fc4a7f7

2 files changed

Lines changed: 200 additions & 56 deletions

File tree

.github/workflows/prepare-release.yaml

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

811
name: Prepare Release
912

1013
on:
1114
workflow_dispatch:
1215
inputs:
16+
package:
17+
description: 'Which package to release'
18+
required: true
19+
type: choice
20+
options:
21+
- durabletask-js
22+
- durabletask-js-azuremanaged
23+
- azure-functions-durable
1324
version:
1425
description: 'Release version (e.g., 0.2.0-beta.1). Leave empty to auto-increment patch version.'
1526
required: false
@@ -32,10 +43,45 @@ jobs:
3243
with:
3344
node-version: '22'
3445

46+
- name: Resolve package metadata
47+
id: resolve-package
48+
run: |
49+
PACKAGE="${{ github.event.inputs.package }}"
50+
case "$PACKAGE" in
51+
durabletask-js)
52+
PKG_DIR="packages/durabletask-js"
53+
NPM_NAME="@microsoft/durabletask-js"
54+
TAG_PREFIX="v"
55+
CHANGELOG_PATH="CHANGELOG.md"
56+
;;
57+
durabletask-js-azuremanaged)
58+
PKG_DIR="packages/durabletask-js-azuremanaged"
59+
NPM_NAME="@microsoft/durabletask-js-azuremanaged"
60+
TAG_PREFIX="azuremanaged-v"
61+
CHANGELOG_PATH="CHANGELOG.md"
62+
;;
63+
azure-functions-durable)
64+
PKG_DIR="packages/azure-functions-durable"
65+
NPM_NAME="durable-functions"
66+
TAG_PREFIX="durable-functions-v"
67+
CHANGELOG_PATH="packages/azure-functions-durable/CHANGELOG.md"
68+
;;
69+
*)
70+
echo "::error::Unknown package '$PACKAGE'. Expected one of: durabletask-js, durabletask-js-azuremanaged, azure-functions-durable."
71+
exit 1
72+
;;
73+
esac
74+
echo "pkg_dir=$PKG_DIR" >> $GITHUB_OUTPUT
75+
echo "npm_name=$NPM_NAME" >> $GITHUB_OUTPUT
76+
echo "tag_prefix=$TAG_PREFIX" >> $GITHUB_OUTPUT
77+
echo "changelog_path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
78+
echo "Releasing '$PACKAGE': dir=$PKG_DIR npm=$NPM_NAME tag_prefix=$TAG_PREFIX changelog=$CHANGELOG_PATH"
79+
3580
- name: Get current version
3681
id: get-current-version
3782
run: |
38-
CURRENT_VERSION=$(node -p "require('./packages/durabletask-js/package.json').version")
83+
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
84+
CURRENT_VERSION=$(node -p "require('./${PKG_DIR}/package.json').version")
3985
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
4086
echo "Current version: $CURRENT_VERSION"
4187
@@ -77,10 +123,17 @@ jobs:
77123
id: get-latest-tag
78124
run: |
79125
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
80-
# Get the latest tag that looks like a version (v*), excluding the
81-
# tag being created so that re-runs don't use it as the baseline.
82-
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | \
83-
grep -v "^v${NEW_VERSION}$" | head -n 1)
126+
TAG_PREFIX="${{ steps.resolve-package.outputs.tag_prefix }}"
127+
# Get the latest tag for THIS package (its tag prefix), excluding the tag being
128+
# created so that re-runs don't use it as the baseline. The legacy core prefix "v"
129+
# still matches historical tags like v0.3.0 (intended, backward compatible) and never
130+
# matches the "azuremanaged-v"/"durable-functions-v" namespaces (they start with a/d, not v).
131+
# Without versionsort.suffix=-, git ranks a prerelease (e.g. X-beta.1) ABOVE its own GA
132+
# (X), so the release AFTER GA (e.g. X.0.1) would resolve LATEST_TAG to the prerelease and
133+
# re-list every commit already shipped in GA. Treating "-" as the prerelease separator
134+
# orders X-beta.1 below X.
135+
LATEST_TAG=$(git -c versionsort.suffix=- tag -l "${TAG_PREFIX}*" --sort=-v:refname | \
136+
grep -v "^${TAG_PREFIX}${NEW_VERSION}$" | head -n 1)
84137
if [ -z "$LATEST_TAG" ]; then
85138
echo "No previous release tag found, using initial commit"
86139
LATEST_TAG=$(git rev-list --max-parents=0 HEAD)
@@ -94,12 +147,17 @@ jobs:
94147
LATEST_TAG="${{ steps.get-latest-tag.outputs.latest_tag }}"
95148
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
96149
97-
echo "Generating changelog for changes between $LATEST_TAG and HEAD..."
150+
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
151+
echo "Generating changelog for changes between $LATEST_TAG and HEAD (scoped to $PKG_DIR)..."
98152
99-
# Get commits between last tag and HEAD.
153+
# Get commits between last tag and HEAD, scoped to the released package's directory via the
154+
# "-- $PKG_DIR" path filter so a release only lists commits that touched that package.
155+
# Without it every package's commits are listed -- and since a package with no release tag
156+
# yet falls back to the repo's initial commit, the first release would dump the ENTIRE repo
157+
# history into that package's changelog.
100158
# GitHub squash-merges put the PR number in parentheses, e.g. "Some change (#123)".
101159
# We convert "(#N)" to a markdown link.
102-
CHANGELOG_CONTENT=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" --no-merges | \
160+
CHANGELOG_CONTENT=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" --no-merges -- "$PKG_DIR" | \
103161
sed 's/(#\([0-9]*\))/([#\1](https:\/\/github.com\/microsoft\/durabletask-js\/pull\/\1))/' | \
104162
sed 's/^/- /' | \
105163
grep -v '^- $' || echo "")
@@ -111,84 +169,126 @@ jobs:
111169
echo "Generated changelog:"
112170
cat /tmp/changelog_content.txt
113171
114-
- name: Update package versions
172+
- name: Update package version
115173
run: |
116174
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
175+
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
117176
118-
echo "Updating packages to version $NEW_VERSION..."
177+
echo "Updating ${PKG_DIR} to version $NEW_VERSION..."
119178
120-
# Update durabletask-js package.json
121179
node -e "
122180
const fs = require('fs');
123-
const pkg = JSON.parse(fs.readFileSync('packages/durabletask-js/package.json', 'utf8'));
181+
const path = '${PKG_DIR}/package.json';
182+
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
124183
pkg.version = '$NEW_VERSION';
125-
fs.writeFileSync('packages/durabletask-js/package.json', JSON.stringify(pkg, null, 2) + '\n');
184+
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
126185
"
127186
128-
# Update durabletask-js-azuremanaged package.json
129-
node -e "
130-
const fs = require('fs');
131-
const pkg = JSON.parse(fs.readFileSync('packages/durabletask-js-azuremanaged/package.json', 'utf8'));
132-
pkg.version = '$NEW_VERSION';
133-
// Also update peer dependency to the new version
134-
if (pkg.peerDependencies && pkg.peerDependencies['@microsoft/durabletask-js']) {
135-
pkg.peerDependencies['@microsoft/durabletask-js'] = '>=$NEW_VERSION';
136-
}
137-
fs.writeFileSync('packages/durabletask-js-azuremanaged/package.json', JSON.stringify(pkg, null, 2) + '\n');
138-
"
139-
140-
echo "Updated package.json files:"
141-
grep '"version"' packages/durabletask-js/package.json
142-
grep '"version"' packages/durabletask-js-azuremanaged/package.json
187+
echo "Updated ${PKG_DIR}/package.json:"
188+
grep '"version"' "${PKG_DIR}/package.json"
143189
144-
- name: Update CHANGELOG.md
190+
- name: Update changelog
145191
run: |
146192
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
147193
CHANGELOG_FILE="${{ steps.changelog-diff.outputs.changelog_file }}"
194+
CHANGELOG_PATH="${{ steps.resolve-package.outputs.changelog_path }}"
148195
RELEASE_DATE=$(date +%Y-%m-%d)
149-
node scripts/update-changelog.js "$NEW_VERSION" "$RELEASE_DATE" "$CHANGELOG_FILE"
196+
node scripts/update-changelog.js "$NEW_VERSION" "$RELEASE_DATE" "$CHANGELOG_FILE" "$CHANGELOG_PATH"
197+
198+
- name: Verify pinned core dependency is published on npm
199+
if: ${{ github.event.inputs.package == 'azure-functions-durable' }}
200+
run: |
201+
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
202+
PINNED=$(node -p "require('./${PKG_DIR}/package.json').dependencies['@microsoft/durabletask-js'] || ''")
203+
if [ -z "$PINNED" ]; then
204+
echo "::error::Could not read the @microsoft/durabletask-js pin from ${PKG_DIR}/package.json."
205+
exit 1
206+
fi
207+
echo "durable-functions pins @microsoft/durabletask-js@${PINNED}; verifying it is published on public npm..."
208+
# Query the public registry explicitly: durable-functions is installed from npmjs.org.
209+
# The repo has NO committed .npmrc; instead root package.json's "preinstall" hook
210+
# (scripts/preinstallNpmrc.js) generates a gitignored .npmrc via scripts/setupNpmrc.js
211+
# that points npm at the azfunc Azure DevOps feed for @microsoft.com users and azfunc CI
212+
# builds. Forcing --registry keeps this check on public npm regardless of any such
213+
# generated (or machine-level) npm config, which would otherwise 401 on this query.
214+
PUBLISHED=$(npm view "@microsoft/durabletask-js@${PINNED}" version --registry https://registry.npmjs.org/ 2>/dev/null || true)
215+
if [ "$PUBLISHED" = "$PINNED" ]; then
216+
echo "OK: @microsoft/durabletask-js@${PINNED} is published on npm."
217+
else
218+
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."
219+
exit 1
220+
fi
150221
151222
- name: Create release branch and commit
152223
id: create-branch
153224
run: |
154225
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
155-
BRANCH_NAME="release/v${NEW_VERSION}"
226+
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
227+
NPM_NAME="${{ steps.resolve-package.outputs.npm_name }}"
228+
TAG_PREFIX="${{ steps.resolve-package.outputs.tag_prefix }}"
229+
CHANGELOG_PATH="${{ steps.resolve-package.outputs.changelog_path }}"
230+
TAG_NAME="${TAG_PREFIX}${NEW_VERSION}"
231+
BRANCH_NAME="release/${TAG_NAME}"
156232
157233
git config user.name "github-actions[bot]"
158234
git config user.email "github-actions[bot]@users.noreply.github.com"
159235
160236
# Create and checkout release branch (idempotent)
161237
git checkout -B "$BRANCH_NAME"
162238
163-
# Stage and commit changes
164-
git add packages/durabletask-js/package.json
165-
git add packages/durabletask-js-azuremanaged/package.json
166-
git add CHANGELOG.md
167-
git commit -m "Release v${NEW_VERSION}"
239+
# Stage and commit changes (only the released package and its changelog)
240+
git add "${PKG_DIR}/package.json"
241+
git add "${CHANGELOG_PATH}"
242+
git commit -m "Release ${NPM_NAME}@${NEW_VERSION}"
168243
169244
# Create release tag (idempotent)
170-
git tag -f "v${NEW_VERSION}"
245+
git tag -f "$TAG_NAME"
171246
172247
# Push branch and tag (force to handle re-runs)
173248
git push -f origin "$BRANCH_NAME"
174-
git push -f origin "v${NEW_VERSION}"
249+
git push -f origin "$TAG_NAME"
175250
176251
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
177-
echo "Created branch $BRANCH_NAME and tag v${NEW_VERSION}"
252+
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
253+
echo "Created branch $BRANCH_NAME and tag $TAG_NAME"
178254
179255
- name: Summary
180256
run: |
181257
NEW_VERSION="${{ steps.calc-version.outputs.new_version }}"
258+
PKG_DIR="${{ steps.resolve-package.outputs.pkg_dir }}"
259+
NPM_NAME="${{ steps.resolve-package.outputs.npm_name }}"
182260
BRANCH_NAME="${{ steps.create-branch.outputs.branch_name }}"
261+
TAG_NAME="${{ steps.create-branch.outputs.tag_name }}"
262+
263+
# Derive the npm dist-tag from the version string. A prerelease (contains "-") must
264+
# publish under a non-default tag so it does NOT move the "latest" dist-tag.
265+
if [[ "$NEW_VERSION" == *-* ]]; then
266+
PUBLISH_CMD="npm publish --registry https://registry.npmjs.org/ --tag preview"
267+
else
268+
PUBLISH_CMD="npm publish --registry https://registry.npmjs.org/"
269+
fi
183270
184271
echo "## Release Preparation Complete! :rocket:" >> $GITHUB_STEP_SUMMARY
185272
echo "" >> $GITHUB_STEP_SUMMARY
186-
echo "- **Version**: v${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY
273+
echo "- **Package**: ${NPM_NAME}" >> $GITHUB_STEP_SUMMARY
274+
echo "- **Version**: ${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY
187275
echo "- **Branch**: ${BRANCH_NAME}" >> $GITHUB_STEP_SUMMARY
188-
echo "- **Tag**: v${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY
276+
echo "- **Tag**: ${TAG_NAME}" >> $GITHUB_STEP_SUMMARY
189277
echo "" >> $GITHUB_STEP_SUMMARY
190278
echo "### Next Step: Create a Pull Request" >> $GITHUB_STEP_SUMMARY
191279
echo "" >> $GITHUB_STEP_SUMMARY
192-
echo "Manually create a PR from **${BRANCH_NAME}** → **main** with title: **Release v${NEW_VERSION}**" >> $GITHUB_STEP_SUMMARY
280+
echo "Manually create a PR from **${BRANCH_NAME}** → **main** with title: **Release ${NPM_NAME}@${NEW_VERSION}**" >> $GITHUB_STEP_SUMMARY
281+
echo "" >> $GITHUB_STEP_SUMMARY
282+
echo "[Create PR](https://github.com/microsoft/durabletask-js/compare/main...${BRANCH_NAME}?expand=1&title=Release+${NPM_NAME}@${NEW_VERSION})" >> $GITHUB_STEP_SUMMARY
193283
echo "" >> $GITHUB_STEP_SUMMARY
194-
echo "[Create PR](https://github.com/microsoft/durabletask-js/compare/main...${BRANCH_NAME}?expand=1&title=Release+v${NEW_VERSION})" >> $GITHUB_STEP_SUMMARY
284+
echo "### Then Publish" >> $GITHUB_STEP_SUMMARY
285+
echo "" >> $GITHUB_STEP_SUMMARY
286+
echo "After the release PR is merged, publish from the package directory:" >> $GITHUB_STEP_SUMMARY
287+
echo '```bash' >> $GITHUB_STEP_SUMMARY
288+
echo "cd ${PKG_DIR}" >> $GITHUB_STEP_SUMMARY
289+
echo "${PUBLISH_CMD}" >> $GITHUB_STEP_SUMMARY
290+
echo '```' >> $GITHUB_STEP_SUMMARY
291+
if [[ "$NEW_VERSION" == *-* ]]; then
292+
echo "" >> $GITHUB_STEP_SUMMARY
293+
echo "> :warning: This is a prerelease. Omitting \`--tag preview\` would move the \`latest\` dist-tag to a prerelease version." >> $GITHUB_STEP_SUMMARY
294+
fi

scripts/update-changelog.js

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,66 @@
11
#!/usr/bin/env node
2-
// This script updates CHANGELOG.md with a new release section
3-
// Usage: node update-changelog.js <version> <date> <changelog-file>
2+
// This script updates a changelog file with a new release section.
3+
// Usage: node update-changelog.js <version> <date> <changelog-file> [target-changelog]
4+
// <changelog-file> file containing the generated changelog entries to insert
5+
// [target-changelog] changelog to update in place (default: CHANGELOG.md)
46

57
const fs = require('fs');
68

7-
const [,, version, releaseDate, changelogFile] = process.argv;
9+
const [,, version, releaseDate, changelogFile, targetChangelog = 'CHANGELOG.md'] = process.argv;
810

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

1416
const changelogContent = fs.readFileSync(changelogFile, 'utf8').trim();
15-
let content = fs.readFileSync('CHANGELOG.md', 'utf8');
17+
let content = fs.readFileSync(targetChangelog, 'utf8');
18+
19+
// One-time normalization for the legacy azure-functions-durable changelog. It ships as a
20+
// placeholder skeleton ("# Changelog" + "## TBD" + a "Details to be finalized" bullet) instead of
21+
// the "## Upcoming"/"## v*" structure this script expects. Left as-is, the first release would
22+
// prepend new sections and strand that skeleton at the bottom, producing a mixed-format file.
23+
// Match ONLY that exact pristine skeleton (CRLF/LF tolerant) and swap in the standard empty
24+
// Upcoming scaffold; any real curated "## TBD" notes have a different shape and are left untouched.
25+
const nonEmptyLines = content.split('\n').map((line) => line.replace(/\r$/, '').trim()).filter(Boolean);
26+
const isLegacySkeleton =
27+
nonEmptyLines.length === 3 &&
28+
nonEmptyLines[0] === '# Changelog' &&
29+
nonEmptyLines[1] === '## TBD' &&
30+
nonEmptyLines[2] === '- Details to be finalized at release time.';
31+
if (isLegacySkeleton) {
32+
content = '## Upcoming\n\n### New\n\n### Fixes\n';
33+
}
34+
35+
// Promote any curated notes from the current "## Upcoming" section into the release section.
36+
// A subsection that is just an empty scaffold heading (e.g. "### New" with nothing under it) is
37+
// dropped, so cutting a release with an untouched Upcoming section promotes nothing and produces
38+
// byte-for-byte the same output as before.
39+
let promoted = '';
40+
const currentUpcoming = content.match(/## Upcoming[\s\S]*?(?=\n## v|$)/);
41+
if (currentUpcoming) {
42+
const body = currentUpcoming[0].replace(/^## Upcoming[^\n]*\n?/, '');
43+
const kept = [];
44+
for (const part of body.split(/(?=^### )/m)) {
45+
const heading = part.match(/^### [^\n]*/);
46+
if (!heading) {
47+
// Lead-in body text before the first "### " subsection (e.g. a preview notice). Promote it
48+
// too, ahead of the subsections; a whitespace-only scaffold gap promotes nothing.
49+
const lead = part.trim();
50+
if (lead) kept.push(lead);
51+
continue;
52+
}
53+
const subContent = part.slice(heading[0].length).trim();
54+
if (subContent) {
55+
kept.push(`${heading[0].trim()}\n\n${subContent}`);
56+
}
57+
}
58+
promoted = kept.join('\n\n');
59+
}
1660

1761
const newSection = `## v${version} (${releaseDate})
1862
19-
### Changes
63+
${promoted ? promoted + '\n\n' : ''}### Changes
2064
2165
${changelogContent}
2266
`;
@@ -33,5 +77,5 @@ if (upcomingMatch) {
3377
// Reset the Upcoming section to empty
3478
content = content.replace(/## Upcoming[\s\S]*?(?=\n## v)/, '## Upcoming\n\n### New\n\n### Fixes\n\n');
3579

36-
fs.writeFileSync('CHANGELOG.md', content);
37-
console.log('Updated CHANGELOG.md');
80+
fs.writeFileSync(targetChangelog, content);
81+
console.log(`Updated ${targetChangelog}`);

0 commit comments

Comments
 (0)