Skip to content

Commit 081ab11

Browse files
committed
Expand the publishing workflow with syncing the changelog with new changes and updating the docs' changelog page. (#12062)
* - Add a new "sync" command to bin/changelog - Add a docs' changelog page update to the publishing workflow * Fix lint errors. * Fix CodeQL errors. * Move the docs changelog update to the stable-publish job. * Fix publish workflow hardening issues - Pin NuGet/login to SHA instead of mutable @v1 tag - Pin NuGet CLI to v7.3.0 instead of latest - Move version tag to post-merge master commit so it points to the exact state that was published - Remove --tags from first-rc-build git push (RC tag is created via API separately; --tags would push all local tags) - Fix stable Slack message branch link pointing to deleted release branch — now links to master - Handle patch releases in update-docs-changelog.mjs gracefully instead of erroring when no changelog content is provided * Use -F for gh api force so ref update receives a boolean -f sends "force" as the string "true"; -F sends it as JSON true. PATCH /repos/.../git/refs/{ref} expects a boolean for non-fast-forward tag updates. * Fix sync command leaving entry files when all entries already present When all .changelogs/*.json entries were already in the target changelog section, syncCommand returned early without deleting the files. In CI this caused a persistent false-positive: ls still found the files on every subsequent RC build, reported synced=true, and triggered the "Changelog extended with new entries!" Slack notification indefinitely. Files are now deleted in the early-return path too, matching the behavior of the normal sync path.
1 parent 3b5d32d commit 081ab11

3 files changed

Lines changed: 313 additions & 25 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
// Usage: node prepend-docs-changelog.mjs <version> [docs-path]
6+
//
7+
// Changelog content is read from the CHANGELOG_CONTENT environment variable.
8+
//
9+
// If CHANGELOG_CONTENT is set:
10+
// - Same base version at top → replace the entire section
11+
// - Different base version → prepend a new section after [[toc]]
12+
// If CHANGELOG_CONTENT is unset (stable promotion of an RC entry):
13+
// - Updates the version header and release date in place
14+
15+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
16+
const DEFAULT_DOCS = path.join(REPO_ROOT, 'docs/content/guides/upgrade-and-migration/changelog/changelog.md');
17+
18+
const [version, docsPath = DEFAULT_DOCS] = process.argv.slice(2);
19+
20+
if (!version) {
21+
process.stderr.write('Usage: node prepend-docs-changelog.mjs <version> [docs-path]\n');
22+
process.exit(1);
23+
}
24+
25+
// ---------------------------------------------------------------------------
26+
27+
function parseVersion(ver) {
28+
const m = ver.match(/^(\d+)\.(\d+)\.\d+(-rc\d+)?$/);
29+
30+
if (!m) {
31+
process.stderr.write(`Error: invalid version "${ver}" — expected X.Y.Z or X.Y.Z-rcN\n`);
32+
process.exit(1);
33+
}
34+
35+
return { base: `${m[1]}.${m[2]}.${ver.split('.')[2].replace(/-rc\d+$/, '')}`, majorMinor: `${m[1]}.${m[2]}` };
36+
}
37+
38+
function formatDate() {
39+
const d = new Date();
40+
const day = d.getDate();
41+
const suffix = { one: 'st', two: 'nd', few: 'rd', other: 'th' }[new Intl.PluralRules('en-US', { type: 'ordinal' }).select(day)];
42+
43+
return `${d.toLocaleString('en-US', { month: 'long' })} ${day}${suffix}, ${d.getFullYear()}`;
44+
}
45+
46+
function buildEntry(ver, content, majorMinor) {
47+
return [
48+
`## ${ver}`,
49+
'',
50+
`Released on ${formatDate()}`,
51+
'',
52+
'For more information about this release, see:',
53+
`- [Documentation (${majorMinor})](https://handsontable.com/docs/${majorMinor})`,
54+
'',
55+
content.trim().replace(/^### /gm, '#### '),
56+
'',
57+
].join('\n');
58+
}
59+
60+
// ---------------------------------------------------------------------------
61+
62+
const { base, majorMinor } = parseVersion(version);
63+
const trimmedContent = process.env.CHANGELOG_CONTENT?.trim();
64+
const lines = fs.readFileSync(docsPath, 'utf8').split('\n');
65+
66+
// Find the topmost ## section header
67+
const topIdx = lines.findIndex(l => /^## /.test(l));
68+
69+
// Does it belong to the same base version? (e.g. 16.0.0, 16.0.0-rc1, 16.0.0-rc2 all share base 16.0.0)
70+
const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71+
const sameBase = topIdx !== -1 && new RegExp(`^## ${escapedBase}(-rc\\d+)?\\s*$`).test(lines[topIdx]);
72+
73+
if (sameBase && !trimmedContent) {
74+
// Stable promotion: just fix the header and date, leave content untouched
75+
lines[topIdx] = `## ${version}`;
76+
77+
for (let i = topIdx + 1; i < lines.length && !/^## /.test(lines[i]); i++) {
78+
if (lines[i].startsWith('Released on ')) {
79+
lines[i] = `Released on ${formatDate()}`;
80+
break;
81+
}
82+
}
83+
84+
fs.writeFileSync(docsPath, lines.join('\n'), 'utf8');
85+
process.stdout.write(`Promoted to stable ${version}.\n`);
86+
87+
} else if (sameBase && trimmedContent) {
88+
// Replace the existing section entirely
89+
let end = lines.length;
90+
91+
for (let i = topIdx + 1; i < lines.length; i++) {
92+
if (/^## /.test(lines[i])) { end = i; break; }
93+
}
94+
95+
const updated = [...lines.slice(0, topIdx), ...buildEntry(version, trimmedContent, majorMinor).split('\n'), ...lines.slice(end)];
96+
97+
fs.writeFileSync(docsPath, updated.join('\n'), 'utf8');
98+
process.stdout.write(`Replaced docs changelog entry for ${version}.\n`);
99+
100+
} else {
101+
// New base version: prepend after [[toc]]
102+
if (!trimmedContent) {
103+
// Patch release on an existing docs branch: the top entry belongs to the same
104+
// major.minor line but a different patch (e.g. promoting 16.2.1 when 16.2.0 is
105+
// already at the top). There is no RC-sourced content to prepend, so skip.
106+
const escapedMajorMinor = majorMinor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
107+
const sameMajorMinor = topIdx !== -1 &&
108+
new RegExp(`^## ${escapedMajorMinor}\\.`).test(lines[topIdx]);
109+
110+
if (sameMajorMinor) {
111+
process.stdout.write(`Patch release ${version} — docs branch already on ${majorMinor}.x, skipping changelog prepend.\n`);
112+
process.exit(0);
113+
}
114+
115+
process.stderr.write(`Error: no changelog content provided for new version ${version}.\n`);
116+
process.exit(1);
117+
}
118+
119+
const tocIdx = lines.findIndex(l => l.trim() === '[[toc]]');
120+
121+
if (tocIdx === -1) {
122+
process.stderr.write('Error: [[toc]] marker not found in docs changelog.\n');
123+
process.exit(1);
124+
}
125+
126+
let insertAt = tocIdx + 1;
127+
128+
while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
129+
130+
const updated = [...lines.slice(0, insertAt), ...buildEntry(version, trimmedContent, majorMinor).split('\n'), ...lines.slice(insertAt)];
131+
132+
fs.writeFileSync(docsPath, updated.join('\n'), 'utf8');
133+
process.stdout.write(`Prepended docs changelog entry for ${version}.\n`);
134+
}

.github/workflows/publish.yml

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ jobs:
213213
echo "EOF"
214214
} >> $GITHUB_OUTPUT
215215
216+
- name: Update docs changelog
217+
env:
218+
CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }}
219+
run: node .github/scripts/update-docs-changelog.mjs '${{ steps.version.outputs.rc-version }}'
220+
216221
- name: Commit and push
217222
env:
218223
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -225,7 +230,7 @@ jobs:
225230
226231
git commit -m "${RC_VERSION}" || echo "No changes to commit"
227232
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
228-
git push -u origin "${RELEASE_BRANCH}" --tags
233+
git push -u origin "${RELEASE_BRANCH}"
229234
230235
# Create tag via GitHub API so it's automatically verified
231236
gh api repos/${{ github.repository }}/git/refs \
@@ -393,6 +398,7 @@ jobs:
393398
if: github.event_name == 'push'
394399
outputs:
395400
rc-version: ${{ steps.rc-version.outputs.rc-version }}
401+
changelog-synced: ${{ steps.sync-changelog.outputs.synced }}
396402
steps:
397403
- name: Extract version from branch name
398404
id: version
@@ -504,6 +510,33 @@ jobs:
504510
sed -i "s/${PATTERN}/## [${RC_VERSION}] - ${TODAY}/" CHANGELOG.md
505511
echo "Updated CHANGELOG.md: ## [${RC_VERSION}] - ${TODAY}"
506512
513+
- name: Sync pending changelog entries
514+
id: sync-changelog
515+
run: |
516+
if ls .changelogs/*.json 2>/dev/null | grep -q .; then
517+
npm run changelog sync < /dev/null
518+
echo "synced=true" >> $GITHUB_OUTPUT
519+
else
520+
echo "No pending changelog entries to sync."
521+
echo "synced=false" >> $GITHUB_OUTPUT
522+
fi
523+
524+
- name: Extract changelog for this version
525+
id: changelog
526+
run: |
527+
RC_VERSION='${{ steps.rc-version.outputs.rc-version }}'
528+
CONTENT=$(node .github/scripts/extract-changelog.mjs "$RC_VERSION")
529+
{
530+
echo "content<<EOF"
531+
printf '%s\n' "$CONTENT"
532+
echo "EOF"
533+
} >> $GITHUB_OUTPUT
534+
535+
- name: Update docs changelog
536+
env:
537+
CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }}
538+
run: node .github/scripts/update-docs-changelog.mjs '${{ steps.rc-version.outputs.rc-version }}'
539+
507540
- name: Commit and push
508541
env:
509542
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -523,17 +556,6 @@ jobs:
523556
-f ref="refs/tags/${RC_VERSION}" \
524557
-f sha="$(git rev-parse HEAD)"
525558
526-
- name: Extract changelog for this version
527-
id: changelog
528-
run: |
529-
RC_VERSION='${{ steps.rc-version.outputs.rc-version }}'
530-
CONTENT=$(node .github/scripts/extract-changelog.mjs "$RC_VERSION")
531-
{
532-
echo "content<<EOF"
533-
printf '%s\n' "$CONTENT"
534-
echo "EOF"
535-
} >> $GITHUB_OUTPUT
536-
537559
- name: Update GitHub release
538560
env:
539561
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -655,14 +677,17 @@ jobs:
655677
COMMITS_TEXT="No commits found since \`${PREV_TAG}\`."
656678
fi
657679
680+
CHANGELOG_SYNCED='${{ needs.rc-build.outputs.changelog-synced }}'
681+
658682
jq -n \
659-
--arg status "$STATUS_FIELD" \
660-
--arg version "${RC_VERSION}" \
661-
--arg branch "${BRANCH}" \
662-
--arg repo_url "${REPO_URL}" \
663-
--arg prev_tag "${PREV_TAG}" \
664-
--arg commits "$COMMITS_TEXT" \
665-
--arg run_url "${RUN_URL}" \
683+
--arg status "$STATUS_FIELD" \
684+
--arg version "${RC_VERSION}" \
685+
--arg branch "${BRANCH}" \
686+
--arg repo_url "${REPO_URL}" \
687+
--arg prev_tag "${PREV_TAG}" \
688+
--arg commits "$COMMITS_TEXT" \
689+
--arg run_url "${RUN_URL}" \
690+
--arg changelog_synced "${CHANGELOG_SYNCED}" \
666691
'{
667692
text: ("🚀 Pre-release " + $version + " generated – " + $status),
668693
blocks: [
@@ -684,6 +709,12 @@ jobs:
684709
type: "section",
685710
text: { type: "mrkdwn", text: ("*📋 Commits since `" + $prev_tag + "`*\n" + $commits) }
686711
},
712+
if $changelog_synced == "true" then
713+
{
714+
type: "context",
715+
elements: [{ type: "mrkdwn", text: "❗ *Changelog extended with new entries!*" }]
716+
}
717+
else empty end,
687718
{ type: "divider" },
688719
{
689720
type: "section",
@@ -822,7 +853,7 @@ jobs:
822853
run: |
823854
sudo apt-get update
824855
sudo apt-get install -y mono-complete
825-
curl -o /tmp/nuget.exe https://dist.nuget.org/win-x86-commandline/latest/nuget.exe
856+
curl -o /tmp/nuget.exe https://dist.nuget.org/win-x86-commandline/v7.3.0/nuget.exe
826857
sudo bash -c 'echo "#!/bin/bash" > /usr/local/bin/nuget && echo "exec mono /tmp/nuget.exe \"$@\"" >> /usr/local/bin/nuget && chmod +x /usr/local/bin/nuget'
827858
828859
- name: Generate NuGet package
@@ -997,7 +1028,7 @@ jobs:
9971028
name: nuget_pack_${{ needs.stable-build.outputs.version }}
9981029

9991030
- name: NuGet login (OIDC trusted publishing)
1000-
uses: NuGet/login@v1
1031+
uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 # https://github.com/NuGet/login/releases/tag/v1
10011032
id: nuget-login
10021033
with:
10031034
user: ${{ secrets.NUGET_USER }}
@@ -1036,6 +1067,20 @@ jobs:
10361067
echo "Deleting release branch ${RELEASE_BRANCH}..."
10371068
git push origin --delete ${RELEASE_BRANCH}
10381069
1070+
- name: Move version tag to post-merge master commit
1071+
env:
1072+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1073+
run: |
1074+
VERSION='${{ needs.stable-build.outputs.version }}'
1075+
NEW_SHA=$(git rev-parse HEAD) # HEAD is master after the merge step
1076+
1077+
gh api repos/${{ github.repository }}/git/refs/tags/${VERSION} \
1078+
-X PATCH \
1079+
-f sha="$NEW_SHA" \
1080+
-F force=true
1081+
1082+
echo "Tag ${VERSION} updated to ${NEW_SHA} (post-merge master)"
1083+
10391084
- name: Create or update docs production branch
10401085
env:
10411086
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1067,6 +1112,9 @@ jobs:
10671112
fi
10681113
fi
10691114
1115+
- name: Update docs changelog
1116+
run: node .github/scripts/update-docs-changelog.mjs '${{ needs.stable-build.outputs.version }}'
1117+
10701118
- name: Regenerate docs API and legacy versions
10711119
run: |
10721120
cd docs
@@ -1109,17 +1157,15 @@ jobs:
11091157
env:
11101158
JOB_STATUS: ${{ job.status }}
11111159
VERSION: ${{ needs.stable-build.outputs.version }}
1112-
RELEASE_BRANCH: ${{ needs.stable-build.outputs.release-branch }}
11131160
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
11141161
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
11151162
run: |
11161163
if [ "$JOB_STATUS" = "success" ]; then STATUS_FIELD=":white_check_mark: Success"; else STATUS_FIELD=":x: Failed"; fi
1117-
CHANGELOG_URL="${REPO_URL}/blob/${RELEASE_BRANCH}/CHANGELOG.md"
1164+
CHANGELOG_URL="${REPO_URL}/blob/master/CHANGELOG.md"
11181165
11191166
jq -n \
11201167
--arg status "$STATUS_FIELD" \
11211168
--arg version "${VERSION}" \
1122-
--arg branch "${RELEASE_BRANCH}" \
11231169
--arg repo_url "${REPO_URL}" \
11241170
--arg changelog_url "$CHANGELOG_URL" \
11251171
--arg run_url "${RUN_URL}" \
@@ -1136,7 +1182,7 @@ jobs:
11361182
{ type: "mrkdwn", text: ("*Status*\n" + $status) },
11371183
{ type: "mrkdwn", text: "*Type*\n📦 Stable Release" },
11381184
{ type: "mrkdwn", text: ("*Version*\n<https://www.npmjs.com/package/handsontable/v/" + $version + "|" + $version + ">") },
1139-
{ type: "mrkdwn", text: ("*Branch*\n<" + $repo_url + "/tree/" + $branch + "|" + $branch + ">") }
1185+
{ type: "mrkdwn", text: ("*Branch*\n<" + $repo_url + "/tree/master|master>") }
11401186
]
11411187
},
11421188
{ type: "divider" },

0 commit comments

Comments
 (0)