Commit bbf18b86ce for handsontable.com
commit bbf18b86ceba165eb1d32ba30defbfe593db55d7
Author: Krzysztof Budnik <571316+budnix@users.noreply.github.com>
Date: Wed Oct 7 09:53:52 2026 +0200
Fix the docs update in the stable release for the 18.1.2 patch
A release branch runs its own copy of publish.yml. release/18.1.x still
carries the 18.1.0-era stable-publish job, which failed the docs step on
18.1.1 and left prod-docs/18.1 to be fixed by hand.
This replaces only the stable-publish job with the develop version:
- DEV-2725 (#13326): fix the prod-docs branch update (API docs, lockfile,
release App token so the docs deploy runs, gated push).
- DEV-2925 (#13513): on a patch release, sync the release docs delta and
the core version to the prod-docs branch.
Adds the two scripts the job calls and their tests. The release-lockfile
test takes the develop version without its examples (DEV-2714) checks.
diff --git a/.github/scripts/__tests__/release-lockfile.test.mjs b/.github/scripts/__tests__/release-lockfile.test.mjs
index b88c606242..882037060d 100644
--- a/.github/scripts/__tests__/release-lockfile.test.mjs
+++ b/.github/scripts/__tests__/release-lockfile.test.mjs
@@ -7,7 +7,7 @@ import { repoRoot } from '../lib/repo-root.mjs';
// A release must ship the exact dependency set CI already tested. `pnpm-lock.yaml`
// records `specifier: workspace:^` for every in-repo dependency and never a package's
// own version, so bumping the version can never legitimately change it. Any difference
-// during a cut means the specifiers re-resolved -- and 15 of them are `latest`, which
+// during a cut means the specifiers re-resolved -- and 14 of them are `latest`, which
// re-resolves to whatever the registry serves that day.
//
// That is DEV-2667: the 18.1.0-rc1 cut deleted the lockfile and reinstalled, floating
@@ -36,8 +36,20 @@ const EXPECTED_GATES = 6;
// `stable-merge` resolves a pnpm-lock.yaml merge conflict by re-running the resolver,
// which legitimately rewrites the file, so those two calls are deliberately ungated.
+// `stable-publish` adds a third kind: the docs branch gains a real devDependency
+// (`@handsontable/angular-wrapper`), so its lockfile MUST change or every docs deploy
+// dies on ERR_PNPM_OUTDATED_LOCKFILE -- `docs-production.yml` installs with
+// `--frozen-lockfile`. That call cannot use the float gate, which demands zero change;
+// it asserts the intended entry landed and that no other package lost its resolved
+// `name@version` identity instead. It is also confined to a
+// `prod-docs/*` branch that never reaches a registry.
// Pinning the total means a NEW `--lockfile-only` site cannot appear unnoticed.
-const EXPECTED_LOCKFILE_ONLY_CALLS = 5;
+const EXPECTED_LOCKFILE_ONLY_CALLS = 6;
+
+// The two steps that have to agree about the docs outcomes. Named once, because the
+// whole point of the assertions below is that a step id reaches BOTH of them.
+const DOCS_PUSH_STEP = 'Commit and push docs branch';
+const DOCS_RESULT_STEP = 'Set docs result output';
/**
* Every YAML file that GitHub Actions executes: workflows and the composite actions
@@ -129,6 +141,31 @@ function steps(source) {
return found;
}
+/**
+ * One job's source, so an assertion about `stable-publish` cannot be satisfied by a step
+ * that happens to sit in a neighboring job.
+ *
+ * `offset` is what keeps a failure message pointing at the real file: line numbers from
+ * `steps()` are relative to whatever source it was handed.
+ *
+ * @param {string} source Workflow source.
+ * @param {string} name The job key, as written at two-space indent.
+ * @returns {{source: string, offset: number}} The job's lines and its start line.
+ */
+function job(source, name) {
+ const lines = source.split('\n');
+ const start = lines.indexOf(` ${name}:`);
+
+ assert.notEqual(start, -1, `publish.yml: no \`${name}:\` job`);
+
+ const end = lines.findIndex((line, index) => index > start && /^ {2}\S/.test(line));
+
+ return {
+ source: lines.slice(start, end === -1 ? lines.length : end).join('\n'),
+ offset: start,
+ };
+}
+
/**
* The arguments of every `pnpm install` in a file.
*
@@ -172,7 +209,7 @@ test('no workflow or composite action deletes the lockfile', () => {
assert.equal(
pattern.test(clean),
false,
- `${rel}: ${why}, so the next install re-resolves every specifier -- 15 of them are `
+ `${rel}: ${why}, so the next install re-resolves every specifier -- 14 of them are `
+ '`latest` (DEV-2667). It is a local developer script, not a CI step.'
);
}
@@ -199,8 +236,11 @@ test('no new --lockfile-only site appears ungated', () => {
EXPECTED_LOCKFILE_ONLY_CALLS,
`publish.yml: expected ${EXPECTED_LOCKFILE_ONLY_CALLS} \`pnpm install --lockfile-only\` `
+ `calls, found ${found}. Three follow a version bump and are gated; two resolve a `
- + 'pnpm-lock.yaml merge conflict in `stable-merge` and legitimately rewrite the file. '
- + 'A new one needs a deliberate decision about which kind it is (DEV-2667).'
+ + 'pnpm-lock.yaml merge conflict in `stable-merge` and legitimately rewrite the file; '
+ + 'one adds the docs devDependency on the `prod-docs/*` branch, and is checked for '
+ + 'that entry landing with no other package losing its resolved identity. A new one '
+ + 'needs a deliberate decision about which kind it is '
+ + '(DEV-2667).'
);
});
@@ -241,3 +281,73 @@ test('every release commit is preceded by the gate', () => {
);
}
});
+
+// Every docs step in `stable-publish` is `continue-on-error`, so a failure there sets
+// `conclusion` to success and survives only in `outcome`. Two steps have to read it:
+// `docs-commit`, which must not push a branch assembled from failed steps, and
+// `docs-result`, which is the only place an operator learns which step broke. Carrying
+// an `id:` is what marks a step's result as meant to be read -- `Purge jsDelivr CDN
+// cache` deliberately has none, because the CDN cannot affect what the branch contains.
+test('every docs step outcome reaches the push gate and the summary', () => {
+ const { source, offset } = job(read('.github/workflows/publish.yml'), 'stable-publish');
+ const all = steps(source);
+ const gate = all.find(step => step.name === DOCS_PUSH_STEP);
+ const summary = all.find(step => step.name === DOCS_RESULT_STEP);
+
+ assert.ok(gate, `publish.yml: no "${DOCS_PUSH_STEP}" step in \`stable-publish\``);
+ assert.ok(summary, `publish.yml: no "${DOCS_RESULT_STEP}" step in \`stable-publish\``);
+
+ // `docs-result` is not `continue-on-error`, so the filter drops it by itself; the gate
+ // is excluded by name, because it cannot gate itself.
+ const reported = all.filter(step => step !== gate
+ && /\n\s+continue-on-error:\s*true/.test(step.body)
+ && /\n\s+id:\s*\S+/.test(step.body));
+
+ assert.ok(
+ reported.length > 0,
+ 'publish.yml: found no reportable docs steps, so the job slice above is wrong'
+ );
+
+ for (const step of reported) {
+ const [, id] = /\n\s+id:\s*(\S+)/.exec(step.body);
+ const line = step.line + offset;
+
+ assert.ok(
+ gate.body.includes(`steps.${id}.outcome == 'success'`),
+ `publish.yml:${line}: \`${id}\` is continue-on-error, so its failure lives `
+ + `only in \`outcome\`. "${DOCS_PUSH_STEP}" must require `
+ + `\`steps.${id}.outcome == 'success'\`, or a docs branch assembled from a failed `
+ + 'step gets pushed -- and that push triggers docs-production.yml.'
+ );
+
+ assert.ok(
+ summary.body.includes(`${id}:\${{ steps.${id}.outcome }}`),
+ `publish.yml:${line}: \`${id}\` is missing from "${DOCS_RESULT_STEP}"'s `
+ + 'OUTCOMES, so a failure there names no cause in the job summary and the release '
+ + `looks green. Add \`${id}:\${{ steps.${id}.outcome }}\`.`
+ );
+ }
+});
+
+// A `run:` block is its own shell, so `$VERSION` is empty in any step that does not set
+// it. That does not break loudly -- it WEAKENS whatever is built on it. In
+// `docs-lockfile`, `grep "specifier: ~${VERSION}"` degrades from "the version this
+// release intends" to "any tilde specifier at all", which a stale entry left behind by
+// an earlier partial failure passes.
+test('every step that reads $VERSION also sets it', () => {
+ for (const step of steps(read('.github/workflows/publish.yml'))) {
+ const body = withoutComments(step.body);
+
+ if (!/\$\{?VERSION\b/.test(body)) {
+ continue;
+ }
+
+ assert.ok(
+ /\n\s+VERSION[=:]/.test(body),
+ `publish.yml:${step.line}: this step reads \`$VERSION\` but never sets it. Each `
+ + '`run:` block is a separate shell, so it expands empty and every check built on '
+ + 'it silently passes. Assign it from `needs.<job>.outputs.version`, or hand it in '
+ + 'through `env:`.'
+ );
+ }
+});
diff --git a/.github/scripts/__tests__/set-package-version.test.mjs b/.github/scripts/__tests__/set-package-version.test.mjs
new file mode 100644
index 0000000000..a8142d960a
--- /dev/null
+++ b/.github/scripts/__tests__/set-package-version.test.mjs
@@ -0,0 +1,104 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { repoRoot } from '../lib/repo-root.mjs';
+
+// `set-package-version.mjs` writes the release version onto the docs branch's
+// `handsontable/package.json`. The load-bearing case (DEV-2925 review) is that
+// writing a version the file ALREADY holds is a no-op that still exits 0 -- a
+// fresh minor/major branch is cut from the tag and already records that version,
+// and a false failure there blocks the whole docs push.
+
+const SCRIPT = path.join(repoRoot(), '.github/scripts/set-package-version.mjs');
+
+// A package.json shaped like handsontable's: the version line plus an inline
+// single-line object, which a JSON round-trip would reflow.
+const PKG = `{
+ "name": "handsontable",
+ "version": "18.1.0",
+ "exports": {
+ ".": { "import": "./index.mjs", "require": "./index.js" }
+ }
+}
+`;
+
+/**
+ * @param {string} contents package.json contents.
+ * @returns {{ dir: string, pkg: string }} Fixture dir and the package.json path.
+ */
+function fixture(contents = PKG) {
+ const dir = mkdtempSync(path.join(tmpdir(), 'set-package-version-'));
+ const pkg = path.join(dir, 'package.json');
+
+ writeFileSync(pkg, contents);
+
+ return { dir, pkg };
+}
+
+/**
+ * @param {string[]} args Positional args after the script path.
+ * @returns {{ status: number, stdout: string, stderr: string }}
+ */
+function run(args) {
+ try {
+ const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' });
+
+ return { status: 0, stdout, stderr: '' };
+ } catch (error) {
+ return { status: error.status ?? 1, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
+ }
+}
+
+test('writes a new version, changing only the version line', () => {
+ const { dir, pkg } = fixture();
+
+ try {
+ const result = run(['18.1.1', pkg]);
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(readFileSync(pkg, 'utf8'), PKG.replace('"version": "18.1.0"', '"version": "18.1.1"'));
+ // The inline exports object is untouched -- proves no JSON reflow.
+ assert.match(readFileSync(pkg, 'utf8'), /"import": "\.\/index\.mjs", "require": "\.\/index\.js"/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('is a clean no-op when the version already matches (a fresh minor/major branch)', () => {
+ const { dir, pkg } = fixture();
+
+ try {
+ const result = run(['18.1.0', pkg]);
+
+ // The regression: a bare `next === source` guard exits 1 here and blocks the
+ // docs push on every x.y.0 release.
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /already at version 18\.1\.0/);
+ assert.equal(readFileSync(pkg, 'utf8'), PKG);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('fails when there is no version line', () => {
+ const { dir, pkg } = fixture('{\n "name": "handsontable"\n}\n');
+
+ try {
+ const result = run(['18.1.1', pkg]);
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /no "version" line/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('fails on missing arguments', () => {
+ const result = run([]);
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /Usage:/);
+});
diff --git a/.github/scripts/__tests__/stable-publish-docs-steps.test.mjs b/.github/scripts/__tests__/stable-publish-docs-steps.test.mjs
new file mode 100644
index 0000000000..1b3785a90c
--- /dev/null
+++ b/.github/scripts/__tests__/stable-publish-docs-steps.test.mjs
@@ -0,0 +1,73 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { repoRoot } from '../lib/repo-root.mjs';
+
+// The two DEV-2925 scripts run inside `stable-publish` AFTER the job checks out
+// the prod-docs branch. That branch was cut before these scripts existed, so the
+// checkout DELETES them from the working tree -- running them from
+// `.github/scripts/` there ENOENTs and blocks the docs push on exactly the patch
+// releases the feature targets. The fix: the `Create or update docs production
+// branch` step copies them into $RUNNER_TEMP (which survives the branch switch)
+// BEFORE the checkout, and both consumers run from that copy. Pin that shape, since
+// no fixture test can see the branch switch.
+
+const publishYml = readFileSync(path.join(repoRoot(), '.github/workflows/publish.yml'), 'utf8');
+
+/**
+ * The body of a named job's step, from its `- name:` line to the next step.
+ *
+ * @param {string} name The step name.
+ * @returns {string} The step body.
+ */
+function step(name) {
+ const lines = publishYml.split('\n');
+ const start = lines.findIndex(line => new RegExp(`^\\s+- name:\\s*${name}\\s*$`).test(line));
+
+ assert.notEqual(start, -1, `publish.yml: no step named "${name}"`);
+
+ let end = lines.length;
+
+ for (let i = start + 1; i < lines.length; i++) {
+ if (/^\s+- name:\s/.test(lines[i])) { end = i; break; }
+ }
+
+ return lines.slice(start, end).join('\n');
+}
+
+const DELTA_SCRIPT = 'sync-release-docs-delta.mjs';
+const VERSION_SCRIPT = 'set-package-version.mjs';
+
+test('the docs branch step preserves both scripts in $RUNNER_TEMP before the checkout', () => {
+ const body = step('Create or update docs production branch');
+ // Anchor to the start of a command line, so a comment that merely mentions
+ // `git checkout` does not count as the checkout.
+ const copyIndex = body.search(/^\s*cp /m);
+ const checkoutIndex = body.search(/^\s*git checkout/m);
+
+ assert.ok(copyIndex !== -1, 'publish.yml: the docs branch step must copy the DEV-2925 scripts before checkout');
+ assert.ok(
+ checkoutIndex !== -1 && copyIndex < checkoutIndex,
+ 'publish.yml: the script copy must run BEFORE the branch checkout deletes them'
+ );
+
+ const copyLine = body.split('\n').find(line => line.trimStart().startsWith('cp '));
+
+ assert.match(copyLine, new RegExp(`\\.github/scripts/${DELTA_SCRIPT}`));
+ assert.match(copyLine, new RegExp(`\\.github/scripts/${VERSION_SCRIPT}`));
+ assert.match(copyLine, /"\$RUNNER_TEMP\/"/);
+});
+
+test('both scripts are run from $RUNNER_TEMP, never from the prod-docs working tree', () => {
+ const delta = step('Sync release docs delta to the docs branch');
+ const version = step('Update core version in docs branch');
+
+ assert.match(delta, new RegExp(`node "\\$RUNNER_TEMP/${DELTA_SCRIPT}"`));
+ assert.match(version, new RegExp(`node "\\$RUNNER_TEMP/${VERSION_SCRIPT}"`));
+
+ // The failure mode this guards: invoking them from `.github/scripts/`, which the
+ // prod-docs checkout has removed.
+ assert.doesNotMatch(delta, new RegExp(`node \\.github/scripts/${DELTA_SCRIPT}`));
+ assert.doesNotMatch(version, new RegExp(`node \\.github/scripts/${VERSION_SCRIPT}`));
+});
diff --git a/.github/scripts/__tests__/sync-release-docs-delta.test.mjs b/.github/scripts/__tests__/sync-release-docs-delta.test.mjs
new file mode 100644
index 0000000000..4e32afe445
--- /dev/null
+++ b/.github/scripts/__tests__/sync-release-docs-delta.test.mjs
@@ -0,0 +1,352 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { repoRoot } from '../lib/repo-root.mjs';
+
+// `sync-release-docs-delta.mjs` ports the `docs/content` changes a release
+// introduced (base version -> head version) onto the checked-out prod-docs
+// branch, with `git apply --3way` so a prod-docs edit that does not overlap
+// survives and one that does conflicts fail-closed (DEV-2925). These tests build
+// a throwaway git repo shaped like a prod-docs checkout and drive the script
+// against it -- the script operates on `process.cwd()`, so `cwd: fixture` is what
+// points it at the fixture rather than the real repo.
+
+const SCRIPT = path.join(repoRoot(), '.github/scripts/sync-release-docs-delta.mjs');
+
+const CHANGELOG_18 = 'docs/content/guides/upgrade-and-migration/changelog-18/changelog-18.md';
+const SHADOW_DOM = 'docs/content/guides/tools-and-building/shadow-dom/shadow-dom.md';
+const ROLLING_CHANGELOG = 'docs/content/guides/upgrade-and-migration/changelog/changelog.md';
+// One of the three hand-maintained, tracked api stubs. `docs:api` leaves it alone,
+// so a release edit to it must port like any other doc -- it is NOT excluded.
+const API_STUB = 'docs/content/api/introduction.md';
+const CORE_PKG = 'handsontable/package.json';
+
+const CHANGELOG_18_BASE = `---
+title: Changelog 18.x
+---
+
+These are the release notes for Handsontable 18.x.
+
+## 18.1.0
+
+Released on September 1st, 2026
+
+#### Added
+- The 18.1.0 line.
+`;
+
+const CHANGELOG_18_HEAD = `---
+title: Changelog 18.x
+---
+
+These are the release notes for Handsontable 18.x.
+
+## 18.1.1
+
+Released on September 15th, 2026
+
+#### Fixed
+- The 18.1.1 fix.
+
+## 18.1.0
+
+Released on September 1st, 2026
+
+#### Added
+- The 18.1.0 line.
+`;
+
+const SHADOW_DOM_BASE = `---
+title: Shadow DOM
+---
+
+Salesforce LWS filters composedPath.
+
+## Known limitations
+`;
+
+const SHADOW_DOM_HEAD = `---
+title: Shadow DOM
+---
+
+Salesforce LWS filters composedPath.
+
+Handsontable binds copy, cut, and paste on the grid, the document, and the shadow root.
+
+## Known limitations
+`;
+
+const API_STUB_BASE = '# API introduction\n\nGenerated at 18.1.0.\n';
+const API_STUB_HEAD = '# API introduction\n\nGenerated at 18.1.1.\n';
+
+/**
+ * Build a git repo whose committed state is the release history (base tag, head
+ * tag) and whose working tree is a prod-docs branch cut from the base tag.
+ *
+ * @param {object} options
+ * @param {string} options.headChangelog18 The head tag's changelog-18 content.
+ * @param {string} options.headShadowDom The head tag's shadow-dom content.
+ * @param {string} options.headApiStub The head tag's api-stub content.
+ * @param {Record<string, string>} [options.headNewFiles] Files present ONLY at the head tag (a page the release adds).
+ * @param {(files: { write: Function, dir: string }) => void} [options.mutateProdDocs] Edits applied to the prod-docs working tree on top of the base.
+ * @returns {string} The fixture directory.
+ */
+function makeFixture({
+ headChangelog18 = CHANGELOG_18_HEAD,
+ headShadowDom = SHADOW_DOM_HEAD,
+ headApiStub = API_STUB_HEAD,
+ headNewFiles = {},
+ mutateProdDocs,
+} = {}) {
+ const dir = mkdtempSync(path.join(tmpdir(), 'sync-release-docs-delta-'));
+ const git = args => execFileSync('git', args, { cwd: dir, encoding: 'utf8' });
+ const write = (rel, contents) => {
+ mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
+ writeFileSync(path.join(dir, rel), contents);
+ };
+
+ git(['init', '--quiet', '--initial-branch', 'release']);
+ git(['config', 'user.email', 'test@example.com']);
+ git(['config', 'user.name', 'Test']);
+
+ // Base tag: the 18.1.0 state.
+ write(CHANGELOG_18, CHANGELOG_18_BASE);
+ write(SHADOW_DOM, SHADOW_DOM_BASE);
+ write(ROLLING_CHANGELOG, '# rolling\n\n## 18.1.0\n');
+ write(API_STUB, API_STUB_BASE);
+ write(CORE_PKG, '{\n "version": "18.1.0"\n}\n');
+ git(['add', '.']);
+ git(['commit', '--quiet', '-m', '18.1.0']);
+ git(['tag', '18.1.0']);
+
+ // Head tag: the 18.1.1 state. The rolling changelog also changes, to prove the
+ // one EXCLUDED tree is not carried by the delta.
+ write(CHANGELOG_18, headChangelog18);
+ write(SHADOW_DOM, headShadowDom);
+ write(ROLLING_CHANGELOG, '# rolling\n\n## 18.1.1\n\n## 18.1.0\n');
+ write(API_STUB, headApiStub);
+ write(CORE_PKG, '{\n "version": "18.1.1"\n}\n');
+
+ for (const [rel, contents] of Object.entries(headNewFiles)) {
+ write(rel, contents);
+ }
+
+ git(['add', '.']);
+ git(['commit', '--quiet', '-m', '18.1.1']);
+ git(['tag', '18.1.1']);
+
+ // The prod-docs checkout: cut from the base tag, then carrying its own edits.
+ git(['checkout', '--quiet', '-b', 'prod-docs', '18.1.0']);
+
+ if (mutateProdDocs) {
+ mutateProdDocs({ write, dir });
+ git(['add', '.']);
+ git(['commit', '--quiet', '-m', 'prod-docs edit']);
+ }
+
+ return dir;
+}
+
+/**
+ * @param {string} dir
+ * @param {string[]} args Positional args after the script path.
+ * @returns {{ status: number, stdout: string, stderr: string }}
+ */
+function run(dir, args) {
+ try {
+ const stdout = execFileSync('node', [SCRIPT, ...args], { cwd: dir, encoding: 'utf8' });
+
+ return { status: 0, stdout, stderr: '' };
+ } catch (error) {
+ return { status: error.status ?? 1, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
+ }
+}
+
+const read = (dir, rel) => readFileSync(path.join(dir, rel), 'utf8');
+const gitStatus = dir => execFileSync('git', ['status', '--porcelain', '--', 'docs/content'], { cwd: dir, encoding: 'utf8' }).trim();
+
+test('applies the release docs delta, incl. a tracked api stub, and preserves an unrelated prod-docs edit', () => {
+ const dir = makeFixture({
+ mutateProdDocs: ({ write }) => {
+ // A docs-sync'd fix on the branch, in a file the release did not touch.
+ write('docs/content/guides/rows/row-moving/row-moving.md', '# Row moving\n\nA prod-docs-only fix.\n');
+ },
+ });
+
+ try {
+ const result = run(dir, ['18.1.0', '18.1.1']);
+
+ assert.equal(result.status, 0, result.stderr);
+ // The per-major section and the mixed-commit shadow-dom edit both land.
+ assert.match(read(dir, CHANGELOG_18), /^## 18\.1\.1$/m);
+ assert.match(read(dir, SHADOW_DOM), /binds copy, cut, and paste/);
+ // The hand-maintained api stub ports too (it is NOT excluded).
+ assert.match(read(dir, API_STUB), /Generated at 18\.1\.1/);
+ // The prod-docs-only edit is untouched.
+ assert.match(read(dir, 'docs/content/guides/rows/row-moving/row-moving.md'), /A prod-docs-only fix\./);
+ // The one excluded tree is NOT carried: the rolling changelog still reads base.
+ assert.doesNotMatch(read(dir, ROLLING_CHANGELOG), /## 18\.1\.1/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('3-way merges a non-overlapping prod-docs edit in a file the release also changed', () => {
+ const dir = makeFixture({
+ mutateProdDocs: ({ write }) => {
+ // Change a CONTEXT line of the release's shadow-dom hunk (the line just
+ // above its inserted paragraph). Plain `git apply` rejects a patch whose
+ // context no longer matches; only `--3way` reconciles it. So this test
+ // fails if `--3way` is dropped.
+ write(SHADOW_DOM, SHADOW_DOM_BASE.replace(
+ 'Salesforce LWS filters composedPath.',
+ 'Salesforce LWS filters composedPath. (prod-docs clarification)'
+ ));
+ },
+ });
+
+ try {
+ const result = run(dir, ['18.1.0', '18.1.1']);
+
+ assert.equal(result.status, 0, result.stderr);
+ // Both the prod-docs clarification and the release's inserted paragraph land.
+ const shadow = read(dir, SHADOW_DOM);
+
+ assert.match(shadow, /prod-docs clarification/);
+ assert.match(shadow, /binds copy, cut, and paste/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('is a no-op when base equals head (a fresh minor/major branch)', () => {
+ const dir = makeFixture();
+
+ try {
+ const result = run(dir, ['18.1.1', '18.1.1']);
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /already at 18\.1\.1/);
+ // The head section was never applied, because base === head short-circuits.
+ assert.doesNotMatch(read(dir, CHANGELOG_18), /## 18\.1\.1/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('is a no-op when the delta touches only the excluded rolling changelog', () => {
+ // Head differs from base ONLY in the rolling changelog, which is excluded, so
+ // there is no content delta to apply.
+ const dir = makeFixture({
+ headChangelog18: CHANGELOG_18_BASE,
+ headShadowDom: SHADOW_DOM_BASE,
+ headApiStub: API_STUB_BASE,
+ });
+
+ try {
+ const result = run(dir, ['18.1.0', '18.1.1']);
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /No docs\/content delta/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('fails closed when a prod-docs edit conflicts with a release change', () => {
+ const dir = makeFixture({
+ mutateProdDocs: ({ write }) => {
+ // Insert a competing paragraph at the SAME gap the release inserts into,
+ // so the two additions overlap and `git apply --3way` conflicts.
+ write(SHADOW_DOM, SHADOW_DOM_BASE.replace(
+ '\n## Known limitations\n',
+ '\nA different prod-docs paragraph added in the very same gap.\n\n## Known limitations\n'
+ ));
+ },
+ });
+
+ try {
+ const result = run(dir, ['18.1.0', '18.1.1']);
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /could not cleanly apply/);
+ assert.match(result.stderr, /shadow-dom\.md/);
+ // The failure path restores the delta's paths, so no conflict markers linger.
+ assert.doesNotMatch(read(dir, SHADOW_DOM), /<{7}|>{7}|={7}/);
+ assert.equal(gitStatus(dir), '', 'expected a clean docs/content tree after cleanup');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('cleanup removes a release-added page when another file conflicts', () => {
+ const NEW_PAGE = 'docs/content/guides/new-feature/new-feature.md';
+ const dir = makeFixture({
+ // The release ADDS a page (absent at base) ...
+ headNewFiles: { [NEW_PAGE]: '# New feature\n\nAdded in 18.1.1.\n' },
+ mutateProdDocs: ({ write }) => {
+ // ... and also conflicts on shadow-dom, so the apply fails after having
+ // staged the new page. `git checkout HEAD -- <new page>` would abort the
+ // whole restore because HEAD has no such path; the script must `git rm` it.
+ write(SHADOW_DOM, SHADOW_DOM_BASE.replace(
+ '\n## Known limitations\n',
+ '\nA different prod-docs paragraph added in the very same gap.\n\n## Known limitations\n'
+ ));
+ },
+ });
+
+ try {
+ const result = run(dir, ['18.1.0', '18.1.1']);
+
+ assert.equal(result.status, 1);
+ // The added page is gone from disk and the index; the conflicted file is clean.
+ assert.equal(existsSync(path.join(dir, NEW_PAGE)), false, 'the release-added page must be removed on cleanup');
+ assert.doesNotMatch(read(dir, SHADOW_DOM), /<{7}|>{7}|={7}/);
+ assert.equal(gitStatus(dir), '', 'expected a clean docs/content tree after cleanup');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('fails when a version ref does not resolve', () => {
+ const dir = makeFixture();
+
+ try {
+ const result = run(dir, ['18.0.9', '18.1.1']);
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /does not resolve to a commit/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('fails on missing arguments', () => {
+ const dir = makeFixture();
+
+ try {
+ const result = run(dir, ['18.1.0']);
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /Usage:/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// The apply MUST stay 3-way (so a non-overlapping prod-docs edit survives) and MUST
+// carry `--binary` (so an asset change round-trips), and the rolling changelog MUST
+// stay excluded while `content/api` MUST NOT be excluded (its stubs have to port).
+// These are load-bearing against the job's fail-open history, and a careless edit to
+// the script would not necessarily fail the behavioral cases above, so pin the source.
+test('the script applies 3-way with --binary, excludes only the rolling changelog', () => {
+ const source = readFileSync(SCRIPT, 'utf8');
+
+ assert.match(source, /'apply', '--3way'/, 'the release-docs delta must be applied with `git apply --3way`');
+ assert.match(source, /'diff', '--binary'/, 'the delta must be produced with `git diff --binary`');
+ assert.match(source, /:\(exclude\)docs\/content\/guides\/upgrade-and-migration\/changelog\/changelog\.md/);
+ assert.doesNotMatch(source, /:\(exclude\)docs\/content\/api/, 'the api tree must NOT be excluded -- its tracked stubs must port');
+});
diff --git a/.github/scripts/set-package-version.mjs b/.github/scripts/set-package-version.mjs
new file mode 100644
index 0000000000..887f019560
--- /dev/null
+++ b/.github/scripts/set-package-version.mjs
@@ -0,0 +1,55 @@
+/**
+ * Set the `version` field of a package.json via a targeted line replace, so the
+ * diff is exactly the version line. A JSON round-trip would reflow inline objects
+ * (handsontable/package.json's `exports` map is written one entry per line), which
+ * is a large, wrong diff.
+ *
+ * Usage: node set-package-version.mjs <version> [package-json-path]
+ *
+ * Writing the value the file already holds is a clean no-op that still exits 0 --
+ * a fresh minor/major docs branch is cut from the release tag (publish.yml), whose
+ * package.json already records the release version, so this MUST succeed there.
+ * Only a genuinely missing `version` line is an error. Conflating "already correct"
+ * with "line absent" (via a bare `next === source` check) fails every x.y.0 release
+ * and blocks the docs push (DEV-2925 review).
+ */
+import { readFileSync, writeFileSync } from 'node:fs';
+
+// The top-level `"version": "x.y.z",` line. `^...$` with the `m` flag and the
+// trailing `",` anchor it to the package version, not a nested `"version"` value.
+const VERSION_LINE = /^(\s*"version":\s*")[^"]*(",)/m;
+
+/**
+ * @returns {number} Process exit code.
+ */
+function main() {
+ const [version, pkgPath = 'handsontable/package.json'] = process.argv.slice(2);
+
+ if (!version) {
+ process.stderr.write('Usage: node set-package-version.mjs <version> [package-json-path]\n');
+
+ return 1;
+ }
+
+ const source = readFileSync(pkgPath, 'utf8');
+
+ if (!VERSION_LINE.test(source)) {
+ process.stderr.write(`::error::no "version" line found in ${pkgPath}\n`);
+
+ return 1;
+ }
+
+ const next = source.replace(VERSION_LINE, `$1${version}$2`);
+
+ writeFileSync(pkgPath, next);
+
+ if (next === source) {
+ process.stdout.write(`${pkgPath} already at version ${version}; no change.\n`);
+ } else {
+ process.stdout.write(`Set ${pkgPath} version to ${version}.\n`);
+ }
+
+ return 0;
+}
+
+process.exitCode = main();
diff --git a/.github/scripts/sync-release-docs-delta.mjs b/.github/scripts/sync-release-docs-delta.mjs
new file mode 100644
index 0000000000..b0ffeefa40
--- /dev/null
+++ b/.github/scripts/sync-release-docs-delta.mjs
@@ -0,0 +1,182 @@
+/**
+ * Apply the `docs/content` changes a release introduced onto the checked-out
+ * `prod-docs/<major.minor>` branch, layered under the branch's own edits.
+ *
+ * Why this exists (DEV-2925): on a PATCH release, `stable-publish` checks out the
+ * existing prod-docs branch and inherits nothing from the release, so every
+ * release-authored docs change stays frozen at the minor's cut -- the version
+ * label, the per-major `changelog-<major>.md` section, and any `docs/content`
+ * edit that rode in a mixed (source+docs) commit that `docs-sync` skips. On a
+ * MINOR/MAJOR release the branch is cut fresh from the tag, so the delta computed
+ * here is empty and this is a no-op. Making it unconditional turns the
+ * release-type difference into one invariant with no branch conditional.
+ *
+ * Usage: node .github/scripts/sync-release-docs-delta.mjs <baseVersion> <headVersion>
+ *
+ * `<baseVersion>` is the version the prod-docs branch currently records
+ * (`handsontable/package.json` on the checked-out branch). `<headVersion>` is the
+ * release being published. The delta is `git diff <baseVersion>..<headVersion>`
+ * restricted to `docs/content`, excluding the rolling `changelog/changelog.md`
+ * (owned by `update-docs-changelog.mjs`) and the regenerated, gitignored
+ * `content/api` reference. It is applied with `git apply --3way`, so a hunk that
+ * conflicts with a prod-docs edit fails the run (fail-closed): the caller's
+ * `continue-on-error` step then skips the push and names the step in the summary.
+ */
+import { execFileSync } from 'node:child_process';
+
+// The checkout to operate on is the caller's working directory: in
+// `stable-publish` that is the repo root with the prod-docs branch checked out,
+// and a test points it at a fixture repo. Deliberately not derived from this
+// file's location -- that would always target the real repo and be untestable.
+const ROOT = process.cwd();
+
+// The rolling changelog is written by `update-docs-changelog.mjs` in a sibling
+// step; excluding it keeps this delta from fighting the step that owns it.
+//
+// `content/api` is deliberately NOT excluded. Its generated reference is gitignored,
+// so it never appears in this tracked diff; the only tracked files there are the
+// three hand-maintained stubs (introduction.md, plugins.md, sidebar.js), which the
+// `docs:api` step leaves untouched (its rimraf glob is `!(introduction|plugins).md`
+// and nothing writes sidebar.js). So a release edit to one of them must port here,
+// like every other doc -- excluding the tree would freeze exactly what this fixes.
+const EXCLUDES = [
+ ':(exclude)docs/content/guides/upgrade-and-migration/changelog/changelog.md',
+];
+
+/**
+ * Run git in the checkout, optionally feeding a patch on stdin.
+ *
+ * @param {string[]} args Arguments after `git`.
+ * @param {string} [input] stdin (the patch, for `git apply`).
+ * @returns {string} Trimmed stdout.
+ */
+function git(args, input) {
+ return execFileSync('git', args, {
+ cwd: ROOT,
+ encoding: 'utf8',
+ input,
+ // A whole docs/content delta can be large; never let a diff or an apply
+ // report truncate against the default 1 MB stdio cap.
+ maxBuffer: 256 * 1024 * 1024,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+}
+
+/**
+ * Whether a ref resolves to a commit in this checkout.
+ *
+ * @param {string} ref
+ * @returns {boolean}
+ */
+function commitExists(ref) {
+ try {
+ git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
+
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * @returns {number} Process exit code.
+ */
+function main() {
+ const [baseVersion, headVersion] = process.argv.slice(2);
+
+ if (!baseVersion || !headVersion) {
+ process.stderr.write('Usage: node sync-release-docs-delta.mjs <baseVersion> <headVersion>\n');
+
+ return 1;
+ }
+
+ // A fresh minor/major branch already carries the released version, so its base
+ // equals its head: nothing to port. This is also what makes the step
+ // idempotent -- once the version write lands `headVersion` on the branch, a
+ // re-run computes an empty delta.
+ if (baseVersion === headVersion) {
+ process.stdout.write(`Docs branch already at ${headVersion}; no release delta to apply.\n`);
+
+ return 0;
+ }
+
+ for (const ref of [baseVersion, headVersion]) {
+ if (!commitExists(ref)) {
+ process.stderr.write(`Error: ref "${ref}" does not resolve to a commit in this checkout.\n`);
+
+ return 1;
+ }
+ }
+
+ const range = `${baseVersion}..${headVersion}`;
+ const files = git(['diff', '--name-only', range, '--', 'docs/content', ...EXCLUDES])
+ .split('\n')
+ .filter(Boolean);
+
+ if (files.length === 0) {
+ process.stdout.write(`No docs/content delta between ${baseVersion} and ${headVersion}.\n`);
+
+ return 0;
+ }
+
+ // `--binary` so a change to an image, font, or other asset under docs/content
+ // round-trips through `git apply` instead of emitting an unappliable
+ // "Binary files differ" hunk that would fail the step (docs/content is text-only
+ // today, so this is a guard, not a fix for a live case).
+ const diff = git(['diff', '--binary', range, '--', 'docs/content', ...EXCLUDES]);
+
+ try {
+ // `--3way` merges each hunk against the branch's own copy, so a prod-docs
+ // edit to an untouched part of a file survives; a genuine overlap conflicts
+ // and throws. `--index` stages what applies so the commit step needs no extra
+ // `git add` -- it also requires each delta-touched file to match between the
+ // working tree and the index, which holds because the steps before this one
+ // leave those files clean (the rolling changelog they rewrite is excluded, the
+ // generated api tree is gitignored, and the api stubs are left untouched).
+ git(['apply', '--3way', '--index', '-'], diff);
+ } catch (error) {
+ const conflicts = git(['diff', '--name-only', '--diff-filter=U']).trim();
+
+ process.stderr.write(`Error: could not cleanly apply the release docs delta ${range}.\n`);
+
+ if (conflicts) {
+ process.stderr.write(`Conflicted paths (a prod-docs edit overlaps a release change):\n${conflicts}\n`);
+ process.stderr.write('Resolve them on the prod-docs branch by hand, then re-run this job.\n');
+ } else {
+ process.stderr.write(`${error.stderr ?? error.message ?? ''}\n`);
+ }
+
+ // Restore exactly the delta's own paths, so a partial apply or conflict
+ // markers do not linger in the index/tree. The push is gated off this failure,
+ // but a later step (or a relaxed gate) must never see half-applied content.
+ // `git checkout HEAD -- <paths>` aborts the WHOLE command on any path HEAD does
+ // not know (a page the release ADDS), restoring nothing, so split the paths:
+ // check out the ones HEAD has (restores edits and files the release deleted),
+ // and `git rm` the ones it added. Scoped to `files`, so sibling steps' work is
+ // untouched.
+ try {
+ const tracked = git(['ls-tree', '-r', '--name-only', 'HEAD', '--', ...files])
+ .split('\n')
+ .filter(Boolean);
+ const added = files.filter(file => !tracked.includes(file));
+
+ if (tracked.length) {
+ git(['checkout', 'HEAD', '--', ...tracked]);
+ }
+
+ if (added.length) {
+ git(['rm', '-f', '--quiet', '--ignore-unmatch', '--', ...added]);
+ }
+ } catch {
+ // Best effort: the branch is discarded on a failed run anyway.
+ }
+
+ return 1;
+ }
+
+ process.stdout.write(`Applied the release docs delta ${range}:\n${files.join('\n')}\n`);
+
+ return 0;
+}
+
+process.exitCode = main();
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 8b41f8e34a..3836d6f986 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1662,6 +1662,7 @@ jobs:
with:
ref: ${{ needs.stable-build.outputs.release-branch }}
fetch-depth: 0
+ persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
name: Install pnpm
@@ -1689,7 +1690,16 @@ jobs:
done
echo "All packages published successfully."
- - name: Unpack handsontable tarball for CDN purge
+ # `handsontable/tmp` feeds two later steps: the CDN purge, and `docs:api` --
+ # docs/scripts/jsdoc-convert reads `pathToSource: '../../../handsontable/tmp'`.
+ # Unpacking the published tarball documents exactly what shipped, and is far
+ # cheaper than rebuilding from source.
+ #
+ # `docs-api` guards only against an EMPTY `handsontable/tmp`. A `tar` that dies
+ # partway, after `base.js` has already landed, leaves a partial tree that reads as
+ # fine -- so `docs-commit` gates on this step's outcome as well.
+ - name: Unpack handsontable tarball
+ id: unpack-tarball
continue-on-error: true
run: |
mkdir -p handsontable/tmp
@@ -1715,11 +1725,28 @@ jobs:
continue-on-error: true
run: pnpm install --frozen-lockfile
+ # A push made with `secrets.GITHUB_TOKEN` does not trigger workflows, so the docs
+ # branch must be pushed with the App token or `docs-production.yml` never runs and
+ # the release's docs are never deployed.
+ #
+ # Minted here rather than at the top of the job on purpose. Only the docs steps
+ # need it, so a rotated key or a 5xx from the token endpoint must not fail the job
+ # before `Publish` and cost the release its npm upload. Keeping it late also keeps
+ # the 60-minute installation-token lifetime well clear of `docs-commit`, which is
+ # the last step to use it.
+ - name: Generate release bot token
+ id: release-token
+ continue-on-error: true
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # https://github.com/actions/create-github-app-token/releases/tag/v3.2.0
+ with:
+ client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+
- name: Create or update docs production branch
id: docs-branch
continue-on-error: true
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.release-token.outputs.token }}
run: |
VERSION='${{ needs.stable-build.outputs.version }}'
# Strip patch version: "16.0.0" → "16.0"
@@ -1729,6 +1756,14 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
+ # The prod-docs branch was cut before these DEV-2925 scripts existed, so the
+ # `git checkout "$DOCS_BRANCH"` below deletes them from the working tree
+ # (they are tracked on the release branch, absent on prod-docs). Preserve
+ # them in $RUNNER_TEMP, which survives the branch switch, and run them from
+ # there in the two steps below. They import nothing but `node:` built-ins,
+ # so they are self-contained away from the repo tree.
+ cp .github/scripts/sync-release-docs-delta.mjs .github/scripts/set-package-version.mjs "$RUNNER_TEMP/"
+
if git ls-remote --heads origin "$DOCS_BRANCH" | grep -q "$DOCS_BRANCH"; then
echo "Docs branch ${DOCS_BRANCH} already exists, checking out..."
git checkout "$DOCS_BRANCH"
@@ -1736,33 +1771,39 @@ jobs:
else
echo "Creating new docs branch ${DOCS_BRANCH} from tag ${VERSION}..."
git checkout -b "$DOCS_BRANCH" "$VERSION"
-
- # Remove /content/api/ from docs/.gitignore so generated API docs can be committed
- BEFORE=$(wc -l < docs/.gitignore)
- grep -v '^/content/api/$' docs/.gitignore > docs/.gitignore.tmp && mv docs/.gitignore.tmp docs/.gitignore
- AFTER=$(wc -l < docs/.gitignore)
-
- if [ "$((BEFORE - 1))" != "$AFTER" ]; then
- echo "::error::docs/.gitignore modification produced unexpected result (before: ${BEFORE}, after: ${AFTER})"
- exit 1
- fi
fi
- - name: Regenerate docs API and legacy versions
+ # The generated API reference stays gitignored -- `docs/.gitignore` holds
+ # `/content/api/*` plus three `!` negations, and editing that by line count
+ # broke on every release from 17.1.0 to 18.1.0. The commit step force-adds
+ # the directory instead, which needs no edit and cannot drift.
+
+ # `docs/` is a pnpm workspace package and ships no `package-lock.json`, so the
+ # `npm ci` this step used to run always died with EUSAGE. The `Install
+ # dependencies` step above already populated `docs/node_modules` from the root.
+ # The old third command, `docs:scripts:generate-legacy-docs-versions`, no longer
+ # exists in `docs/package.json` -- this workflow was its last reference.
+ - name: Regenerate docs API
id: docs-api
continue-on-error: true
run: |
- cd docs
- npm ci
- npm run docs:api
- npm run docs:scripts:generate-legacy-docs-versions
+ if [ ! -f handsontable/tmp/base.js ]; then
+ echo "::error::handsontable/tmp is empty, so docs:api has no source to read."
+ echo "::error::The \`Unpack handsontable tarball\` step must succeed before this one."
+ exit 1
+ fi
+
+ npm --prefix docs run docs:api
+ # The old guard here compared line counts. `@handsontable/angular-wrapper` is
+ # absent from `docs/package.json` on the release tag, so adding it always grows
+ # the file by one line and the guard always failed -- on 18.0.0 and 18.1.0 alike,
+ # after the write had already succeeded. Assert the value that was written.
- name: Update @handsontable/angular-wrapper version in docs
id: docs-angular
continue-on-error: true
run: |
VERSION='${{ needs.stable-build.outputs.version }}'
- BEFORE=$(wc -l < docs/package.json)
node -e "
import fs from 'fs';
@@ -1771,12 +1812,93 @@ jobs:
fs.writeFileSync('docs/package.json', JSON.stringify(pkg, null, 2) + '\n', 'utf8');
"
- AFTER=$(wc -l < docs/package.json)
- if [ "$BEFORE" != "$AFTER" ]; then
- echo "::error::docs/package.json modification changed line count (before: ${BEFORE}, after: ${AFTER})"
+ WROTE=$(node -p "require('./docs/package.json').devDependencies['@handsontable/angular-wrapper']")
+ if [ "$WROTE" != "~${VERSION}" ]; then
+ # Revert before failing. The commit step stages `docs/package.json`, so a
+ # bare `exit 1` would leave the bad file on disk to be committed and pushed.
+ git checkout -- docs/package.json
+ echo "::error::docs/package.json devDependency is '${WROTE}', expected '~${VERSION}'"
+ exit 1
+ fi
+
+ # `docs-production.yml` runs `pnpm install --frozen-lockfile` at the repo root, so
+ # the devDependency added above must reach `pnpm-lock.yaml` or every docs deploy
+ # dies on ERR_PNPM_OUTDATED_LOCKFILE. This is the third kind of `--lockfile-only`
+ # call in this file: not a version bump (those are gated by
+ # `lockfile-float-gate.mjs`) and not a merge-conflict resolution, but a real
+ # dependency addition on a docs-only branch that never reaches a registry.
+ # So instead of the float gate's "must not change at all", this step asserts the
+ # intended entry landed and that no OTHER package lost its resolved identity.
+ - name: Update lockfile for the docs devDependency
+ id: docs-lockfile
+ continue-on-error: true
+ run: |
+ # Every `run:` block is its own shell, so this must be set here even though the
+ # sibling steps set it too. Left unset, the assertion below still finds a
+ # `specifier: ~` line and passes on ANY tilde version -- including a stale one
+ # an earlier partial failure left on the branch.
+ VERSION='${{ needs.stable-build.outputs.version }}'
+
+ pnpm install --lockfile-only
+
+ git diff --stat -- pnpm-lock.yaml
+
+ # First, the change must actually have landed. `linkWorkspacePackages: true`
+ # means `~X.Y.0` can resolve to the in-repo wrapper as a `link:`, which adds
+ # nothing under `packages:` -- so the float check below would pass identically
+ # whether pnpm wrote the importer entry or not. This step is the only thing
+ # between the release and ERR_PNPM_OUTDATED_LOCKFILE, so assert the positive.
+ if ! sed -n '/^ docs:$/,/^ [a-zA-Z]/p' pnpm-lock.yaml \
+ | grep -A 2 "'@handsontable/angular-wrapper':" \
+ | grep -q "specifier: ~${VERSION}"; then
+ git checkout -- pnpm-lock.yaml
+ echo "::error::pnpm-lock.yaml's docs importer does not record"
+ echo "::error::'@handsontable/angular-wrapper' at ~${VERSION}, so"
+ echo "::error::docs-production.yml would still fail --frozen-lockfile."
exit 1
fi
+ # Then, nothing else may have moved. A DEV-2667 float re-resolves EXISTING
+ # packages, so it always REMOVES their old `name@version` identity (core-js
+ # 3.37 -> 3.50 removes `core-js@3.37.0`). Checking removals alone is therefore
+ # enough, and it leaves room for the wrapper to pull in a genuinely new
+ # transitive dependency, which only ever adds identities.
+ #
+ # Line counts cannot express this: a first release ADDS the devDependency, but
+ # a patch release onto an existing prod-docs branch REWRITES it -- 18.1.0 ->
+ # 18.1.1 is a delete-plus-add -- so a "no deletions" rule would fail every
+ # patch release's docs step.
+ identities() {
+ sed -n '/^packages:/,/^snapshots:/p' "$1" \
+ | grep -E "^ '?[^ ].*@.*:$" \
+ | sort
+ }
+
+ git show HEAD:pnpm-lock.yaml > /tmp/lock-before.yaml
+ identities /tmp/lock-before.yaml > /tmp/ids-before
+ identities pnpm-lock.yaml > /tmp/ids-after
+
+ REMOVED=$(diff /tmp/ids-before /tmp/ids-after \
+ | grep -E '^<' \
+ | grep -v '@handsontable/angular-wrapper' \
+ || true)
+
+ if [ -n "$REMOVED" ]; then
+ # Revert before failing, or the commit step stages the floated lockfile and
+ # pushes it -- which is the very thing this check exists to stop.
+ git checkout -- pnpm-lock.yaml
+ echo "::error::pnpm-lock.yaml re-resolved packages other than the docs"
+ echo "::error::devDependency, so the docs toolchain floated (DEV-2667):"
+ echo "$REMOVED" | head -40 | while IFS= read -r LINE; do
+ echo "::error:: ${LINE}"
+ done
+ echo "::error::Land the intended lockfile change on develop, let CI verify it,"
+ echo "::error::then re-run this job."
+ exit 1
+ fi
+
+ echo "Only the docs devDependency changed. No other package re-resolved."
+
- name: Update docs changelog
id: docs-changelog
continue-on-error: true
@@ -1784,42 +1906,151 @@ jobs:
CHANGELOG_CONTENT: ${{ steps.docs-changelog-content.outputs.content }}
run: node .github/scripts/update-docs-changelog.mjs '${{ needs.stable-build.outputs.version }}'
+ # DEV-2925: on a PATCH release the docs branch is checked out, not re-cut from
+ # the tag, so every `docs/content` change the release introduced stays frozen
+ # at the minor's cut -- the per-major `changelog-<major>.md` section, and any
+ # edit that rode in a mixed source+docs commit that `docs-sync` skips (the
+ # 18.1.1 `shadow-dom.md` fix). Apply that delta here. `BASE` is the version the
+ # branch currently records; on a fresh minor/major branch it equals the release
+ # version, so the delta is empty and this is a no-op. `git apply --3way` inside
+ # the script keeps prod-docs's own docs-sync'd edits and fails closed on a real
+ # overlap, which (being continue-on-error) then skips the push below. This runs
+ # BEFORE the version write, so `BASE` still reads the previous version.
+ - name: Sync release docs delta to the docs branch
+ id: docs-content-delta
+ continue-on-error: true
+ run: |
+ VERSION='${{ needs.stable-build.outputs.version }}'
+ BASE=$(node -p "require('./handsontable/package.json').version")
+
+ # Run from the $RUNNER_TEMP copy: the prod-docs checkout deleted the script
+ # from the working tree. Both BASE and VERSION are release tags, already
+ # present from the job's `fetch-depth: 0` checkout (a fresh minor/major
+ # short-circuits on BASE == VERSION and resolves neither).
+ node "$RUNNER_TEMP/sync-release-docs-delta.mjs" "$BASE" "$VERSION"
+
+ # DEV-2925: `docs/src/plugins/docs-version.mjs` reads `handsontable/package.json`
+ # `version` as the docs' current version (breadcrumb + example-runner links). On
+ # a patch the checked-out branch still records the previous version. Write the
+ # released version unconditionally -- `set-package-version.mjs` does a targeted
+ # line replace (a JSON round-trip would reflow the inline `exports` map) and is a
+ # clean no-op when the value already matches, which is the case on a fresh branch
+ # cut from the tag. Run it from the $RUNNER_TEMP copy: the prod-docs checkout
+ # deleted the script from the working tree. The core version is a
+ # workspace/`link:` dependency, never pinned in `pnpm-lock.yaml` (a `git diff`
+ # would never show the version), so the write cannot change the lock and
+ # `--frozen-lockfile` at deploy stays green with no lockfile regeneration.
+ - name: Update core version in docs branch
+ id: docs-core-version
+ continue-on-error: true
+ env:
+ VERSION: ${{ needs.stable-build.outputs.version }}
+ run: |
+ node "$RUNNER_TEMP/set-package-version.mjs" "$VERSION" handsontable/package.json
+
+ WROTE=$(node -p "require('./handsontable/package.json').version")
+ if [ "$WROTE" != "$VERSION" ]; then
+ git checkout -- handsontable/package.json
+ echo "::error::handsontable/package.json version is '${WROTE}', expected '${VERSION}'"
+ exit 1
+ fi
+
+ # This used to run a bare `git add .` in a workspace that also holds the
+ # downloaded release artifacts, so every release committed four `_tmp_packs/*.tgz`
+ # (5.5 MB+) onto the docs branch. Stage the paths this job actually produces.
+ # `-f` is what gets the generated API reference past `docs/.gitignore`.
+ #
+ # The `if:` matters more than it looks. Every step above is `continue-on-error`, so
+ # without it this step pushes whatever half-assembled state the failures left --
+ # and because the push now uses the App token, that push TRIGGERS
+ # `docs-production.yml`. A failed `docs-lockfile` would otherwise ship a
+ # `docs/package.json` whose devDependency the lockfile does not match, and the
+ # deploy would die on ERR_PNPM_OUTDATED_LOCKFILE: exactly the failure this job is
+ # meant to prevent. A skipped push leaves the branch untouched and reviewable.
- name: Commit and push docs branch
id: docs-commit
continue-on-error: true
+ if: >-
+ steps.unpack-tarball.outcome == 'success'
+ && steps.release-token.outcome == 'success'
+ && steps.docs-changelog-content.outcome == 'success'
+ && steps.docs-deps.outcome == 'success'
+ && steps.docs-branch.outcome == 'success'
+ && steps.docs-api.outcome == 'success'
+ && steps.docs-angular.outcome == 'success'
+ && steps.docs-lockfile.outcome == 'success'
+ && steps.docs-changelog.outcome == 'success'
+ && steps.docs-content-delta.outcome == 'success'
+ && steps.docs-core-version.outcome == 'success'
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.release-token.outputs.token }}
run: |
VERSION='${{ needs.stable-build.outputs.version }}'
DOCS_BRANCH="${DOCS_BRANCH_PREFIX}${VERSION%.*}"
- git add .
+ # The release delta is already staged by `git apply --index`; this covers the
+ # rolling changelog (also under guides) and adds `handsontable/package.json`
+ # for the version write. Scoped to `guides` -- never all of `docs/content` --
+ # so a stray untracked file dropped elsewhere under content is not committed.
+ git add pnpm-lock.yaml docs/package.json docs/content/guides handsontable/package.json
+ git add -f docs/content/api
git commit -m "${VERSION}" || echo "No changes to commit"
git push origin "$DOCS_BRANCH"
+ # Every docs step above is `continue-on-error`, which sets its `conclusion` to
+ # success and leaves the real result only in `outcome`. The Actions UI therefore
+ # shows a fully green step list even when the branch is unusable -- that is how
+ # 17.1.0, 18.0.0 and 18.1.0 all shipped broken docs branches unnoticed. Name the
+ # failing steps in the job summary, which needs no token and survives log expiry.
- name: Set docs result output
id: docs-result
if: always()
+ env:
+ OUTCOMES: >-
+ unpack-tarball:${{ steps.unpack-tarball.outcome }}
+ release-token:${{ steps.release-token.outcome }}
+ docs-changelog-content:${{ steps.docs-changelog-content.outcome }}
+ docs-deps:${{ steps.docs-deps.outcome }}
+ docs-branch:${{ steps.docs-branch.outcome }}
+ docs-api:${{ steps.docs-api.outcome }}
+ docs-angular:${{ steps.docs-angular.outcome }}
+ docs-lockfile:${{ steps.docs-lockfile.outcome }}
+ docs-changelog:${{ steps.docs-changelog.outcome }}
+ docs-content-delta:${{ steps.docs-content-delta.outcome }}
+ docs-core-version:${{ steps.docs-core-version.outcome }}
+ docs-commit:${{ steps.docs-commit.outcome }}
run: |
- if [ "${{ steps.docs-changelog-content.outcome }}" = "failure" ] || \
- [ "${{ steps.docs-deps.outcome }}" = "failure" ] || \
- [ "${{ steps.docs-branch.outcome }}" = "failure" ] || \
- [ "${{ steps.docs-api.outcome }}" = "failure" ] || \
- [ "${{ steps.docs-angular.outcome }}" = "failure" ] || \
- [ "${{ steps.docs-changelog.outcome }}" = "failure" ] || \
- [ "${{ steps.docs-commit.outcome }}" = "failure" ]; then
+ FAILED=""
+ MISSING=""
+
+ for ENTRY in $OUTCOMES; do
+ STEP="${ENTRY%%:*}"
+ OUTCOME="${ENTRY##*:}"
+
+ case "$OUTCOME" in
+ failure) FAILED="${FAILED} ${STEP}" ;;
+ success) ;;
+ *) MISSING="${MISSING} ${STEP}" ;;
+ esac
+ done
+
+ if [ -n "$FAILED" ]; then
echo "result=failure" >> $GITHUB_OUTPUT
- elif [ "${{ steps.docs-changelog-content.outcome }}" = "success" ] && \
- [ "${{ steps.docs-deps.outcome }}" = "success" ] && \
- [ "${{ steps.docs-branch.outcome }}" = "success" ] && \
- [ "${{ steps.docs-api.outcome }}" = "success" ] && \
- [ "${{ steps.docs-angular.outcome }}" = "success" ] && \
- [ "${{ steps.docs-changelog.outcome }}" = "success" ] && \
- [ "${{ steps.docs-commit.outcome }}" = "success" ]; then
- echo "result=success" >> $GITHUB_OUTPUT
- else
+
+ {
+ echo "### :x: Docs branch update failed"
+ echo
+ echo "Failed steps:"
+ for STEP in $FAILED; do echo "- \`${STEP}\`"; done
+ echo
+ echo "The push was skipped, so the docs branch still holds its previous"
+ echo "state. Fix the cause, then re-run this job."
+ } >> $GITHUB_STEP_SUMMARY
+ elif [ -n "$MISSING" ]; then
echo "result=skipped" >> $GITHUB_OUTPUT
+ else
+ echo "result=success" >> $GITHUB_OUTPUT
fi
# ============================================================