Skip to content

Commit ada6fa9

Browse files
fix: surface gh-pages publish errors via callback (#205) (#206)
fix: surface gh-pages publish errors via callback gh-pages@6 silently absorbs errors via its internal .then(_, onRejected) handler that catches rejections and calls the user callback without rethrowing — which converts the rejection into a fulfilment. The result: git failures (notably HTTPS auth errors during clone) leave publish()'s returned Promise in a resolved state, so angular-cli-ghpages printed its "Successfully published" banner and exited 0 even when nothing was pushed. The v3.0.0 refactor removed a 2019 callback wrapper under the assumption that upstream had fixed this. Upstream commit e1374b3 ("always return a promise") only fixed two early-exit validation paths — not the main chain's error absorption. Issues tschaub/gh-pages#465, #412 and #151 remain open. Restore the callback-based wrapper: the callback always fires with the error, so we bridge it to a rejection of our own Promise. Regression tests: - engine.gh-pages-integration.spec.ts: mock-contract test that encodes the exact bug shape (callback fires with error, promise resolves), plus a "no success banner on failure" assertion. - engine.gh-pages-behavior.spec.ts: spawn-level canary that drives the real gh-pages library through engine.run() with child_process.spawn mocked to emit "fatal: Authentication failed" + exit 128 on clone. Existing mocks across engine.spec.ts, engine-filesystem.spec.ts, and parameter-tests/builder-integration.spec.ts updated to invoke the callback (they previously relied on mockResolvedValue only and would hang once engine started awaiting a callback). Fixes #205
1 parent 2e024fe commit ada6fa9

8 files changed

Lines changed: 203 additions & 34 deletions

src/engine/engine-filesystem.spec.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ describe('engine - real filesystem tests', () => {
6060

6161
const ghpages = require('gh-pages');
6262
jest.spyOn(ghpages, 'clean').mockImplementation(() => {});
63-
jest.spyOn(ghpages, 'publish').mockResolvedValue(undefined);
63+
jest.spyOn(ghpages, 'publish').mockImplementation((_dir: unknown, _opts: unknown, callback?: (error: Error | null) => void) => {
64+
if (callback) {
65+
callback(null);
66+
}
67+
return Promise.resolve(undefined);
68+
});
6469

6570
const options = {
6671
notfound: true,
@@ -84,7 +89,12 @@ describe('engine - real filesystem tests', () => {
8489

8590
const ghpages = require('gh-pages');
8691
jest.spyOn(ghpages, 'clean').mockImplementation(() => {});
87-
jest.spyOn(ghpages, 'publish').mockResolvedValue(undefined);
92+
jest.spyOn(ghpages, 'publish').mockImplementation((_dir: unknown, _opts: unknown, callback?: (error: Error | null) => void) => {
93+
if (callback) {
94+
callback(null);
95+
}
96+
return Promise.resolve(undefined);
97+
});
8898

8999
const options = {
90100
notfound: false,
@@ -104,7 +114,12 @@ describe('engine - real filesystem tests', () => {
104114

105115
const ghpages = require('gh-pages');
106116
jest.spyOn(ghpages, 'clean').mockImplementation(() => {});
107-
jest.spyOn(ghpages, 'publish').mockResolvedValue(undefined);
117+
jest.spyOn(ghpages, 'publish').mockImplementation((_dir: unknown, _opts: unknown, callback?: (error: Error | null) => void) => {
118+
if (callback) {
119+
callback(null);
120+
}
121+
return Promise.resolve(undefined);
122+
});
108123

109124
const options = {
110125
notfound: true,
@@ -173,8 +188,11 @@ describe('engine - real filesystem tests', () => {
173188

174189
let capturedOptions: { cname?: string; nojekyll?: boolean } = {};
175190
const publishSpy = jest.spyOn(ghpages, 'publish').mockImplementation(
176-
(dir: string, options: { cname?: string; nojekyll?: boolean }) => {
191+
(_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => {
177192
capturedOptions = options;
193+
if (callback) {
194+
callback(null);
195+
}
178196
return Promise.resolve();
179197
}
180198
);
@@ -202,8 +220,11 @@ describe('engine - real filesystem tests', () => {
202220

203221
let capturedOptions: { cname?: string; nojekyll?: boolean } = {};
204222
const publishSpy = jest.spyOn(ghpages, 'publish').mockImplementation(
205-
(dir: string, options: { cname?: string; nojekyll?: boolean }) => {
223+
(_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => {
206224
capturedOptions = options;
225+
if (callback) {
226+
callback(null);
227+
}
207228
return Promise.resolve();
208229
}
209230
);
@@ -229,8 +250,11 @@ describe('engine - real filesystem tests', () => {
229250

230251
let capturedOptions: { cname?: string; nojekyll?: boolean } = {};
231252
const publishSpy = jest.spyOn(ghpages, 'publish').mockImplementation(
232-
(dir: string, options: { cname?: string; nojekyll?: boolean }) => {
253+
(_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => {
233254
capturedOptions = options;
255+
if (callback) {
256+
callback(null);
257+
}
234258
return Promise.resolve();
235259
}
236260
);
@@ -263,8 +287,11 @@ describe('engine - real filesystem tests', () => {
263287

264288
let capturedOptions: { cname?: string; nojekyll?: boolean } = {};
265289
const publishSpy = jest.spyOn(ghpages, 'publish').mockImplementation(
266-
(dir: string, options: { cname?: string; nojekyll?: boolean }) => {
290+
(_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => {
267291
capturedOptions = options;
292+
if (callback) {
293+
callback(null);
294+
}
268295
return Promise.resolve();
269296
}
270297
);
@@ -291,8 +318,11 @@ describe('engine - real filesystem tests', () => {
291318

292319
let capturedOptions: { cname?: string; nojekyll?: boolean } = {};
293320
const publishSpy = jest.spyOn(ghpages, 'publish').mockImplementation(
294-
(dir: string, options: { cname?: string; nojekyll?: boolean }) => {
321+
(_dir: string, options: { cname?: string; nojekyll?: boolean }, callback?: (error: Error | null) => void) => {
295322
capturedOptions = options;
323+
if (callback) {
324+
callback(null);
325+
}
296326
return Promise.resolve();
297327
}
298328
);

src/engine/engine.gh-pages-behavior.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,4 +1016,54 @@ describe('gh-pages v6.3.0 - behavioral snapshot', () => {
10161016
* gh-pages.clean() behavior is tested in engine.gh-pages-clean.spec.ts
10171017
* That test uses real filesystem operations without mocks.
10181018
*/
1019+
1020+
/**
1021+
* End-to-end canary for issue #205: drives the REAL gh-pages library
1022+
* through engine.run() with a mocked child_process.spawn that simulates
1023+
* an HTTPS auth failure during `git clone`. If upstream gh-pages ever
1024+
* fixes the `.then(_, onRejected)` absorption pattern, this test will
1025+
* still pass; if they change callback semantics, it will fail loudly.
1026+
*/
1027+
describe('auth-failure silent-swallow regression (issue #205)', () => {
1028+
const engine = require('./engine');
1029+
const { cleanupMonkeypatch } = require('./engine.prepare-options-helpers');
1030+
const { logging } = require('@angular-devkit/core');
1031+
1032+
beforeEach(() => {
1033+
cleanupMonkeypatch();
1034+
});
1035+
1036+
it('engine.run() should reject when git clone fails with fatal: Authentication failed', async () => {
1037+
mockSpawn.mockImplementationOnce((cmd: string, args: string[] | undefined) => {
1038+
const capturedArgs = args || [];
1039+
spawnCalls.push({ cmd, args: capturedArgs, options: undefined });
1040+
const child = createMockChildProcess();
1041+
setImmediate(() => {
1042+
child.stderr!.emit(
1043+
'data',
1044+
Buffer.from(
1045+
"fatal: Authentication failed for 'https://github.com/owner/repo.git'"
1046+
)
1047+
);
1048+
child.emit!('close', 128);
1049+
});
1050+
return child;
1051+
});
1052+
1053+
const options = {
1054+
repo: 'https://x-access-token:bad-token@github.com/owner/repo.git',
1055+
branch: 'gh-pages',
1056+
dotfiles: true,
1057+
notfound: true,
1058+
nojekyll: true
1059+
};
1060+
1061+
await expect(
1062+
engine.run(basePath, options, new logging.NullLogger())
1063+
).rejects.toThrow();
1064+
1065+
expect(spawnCalls[0]?.cmd).toBe('git');
1066+
expect(spawnCalls[0]?.args[0]).toBe('clone');
1067+
});
1068+
});
10191069
});

src/engine/engine.gh-pages-integration.spec.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,14 @@ describe('engine - gh-pages integration', () => {
5454
ghpagesPublishSpy = jest.spyOn(ghpages, 'publish');
5555
}
5656

57-
// Set default mock implementations - gh-pages v5+ uses Promise-based API
57+
// engine uses the callback form of gh-pages.publish() — see #205
5858
ghpagesCleanSpy.mockImplementation(() => {});
59-
ghpagesPublishSpy.mockResolvedValue(undefined);
59+
ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => {
60+
if (callback) {
61+
callback(null);
62+
}
63+
return Promise.resolve(undefined);
64+
});
6065

6166
// Create fresh copy of environment for each test
6267
// This preserves PATH, HOME, etc. needed by git
@@ -124,10 +129,11 @@ describe('engine - gh-pages integration', () => {
124129
await engine.run(testDir, options, logger);
125130

126131
expect(ghpagesPublishSpy).toHaveBeenCalledTimes(1);
127-
// gh-pages v5+ uses Promise-based API (no callback)
132+
// engine uses the callback form of gh-pages.publish() — see #205
128133
expect(ghpagesPublishSpy).toHaveBeenCalledWith(
129134
testDir,
130-
expect.any(Object)
135+
expect.any(Object),
136+
expect.any(Function)
131137
);
132138
});
133139

@@ -515,23 +521,28 @@ describe('engine - gh-pages integration', () => {
515521
});
516522

517523
describe('Promise handling integration', () => {
518-
// gh-pages v5+ uses Promise-based API (we no longer use callback-based approach)
524+
// engine uses the callback form of gh-pages.publish() — see #205
519525

520-
it('should invoke gh-pages.publish() without callback (Promise-based)', async () => {
526+
it('should invoke gh-pages.publish() with a callback', async () => {
521527
const testDir = '/test/dist';
522528
const options = { dotfiles: true, notfound: true, nojekyll: true };
523529

524530
await engine.run(testDir, options, logger);
525531

526-
// gh-pages v5+ Promise API: publish(dir, options) - no callback
527532
expect(ghpagesPublishSpy).toHaveBeenCalledWith(
528533
expect.any(String),
529-
expect.any(Object)
534+
expect.any(Object),
535+
expect.any(Function)
530536
);
531537
});
532538

533-
it('should resolve when gh-pages.publish() resolves', async () => {
534-
ghpagesPublishSpy.mockResolvedValue(undefined);
539+
it('should resolve when gh-pages.publish() invokes the callback with no error', async () => {
540+
ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => {
541+
if (callback) {
542+
callback(null);
543+
}
544+
return Promise.resolve(undefined);
545+
});
535546

536547
const testDir = '/test/dist';
537548
const options = { dotfiles: true, notfound: true, nojekyll: true };
@@ -541,9 +552,14 @@ describe('engine - gh-pages integration', () => {
541552
).resolves.toBeUndefined();
542553
});
543554

544-
it('should reject when gh-pages.publish() rejects', async () => {
555+
it('should reject when gh-pages.publish() invokes the callback with an error', async () => {
545556
const publishError = new Error('Git push failed');
546-
ghpagesPublishSpy.mockRejectedValue(publishError);
557+
ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => {
558+
if (callback) {
559+
callback(publishError);
560+
}
561+
return Promise.resolve(undefined);
562+
});
547563

548564
const testDir = '/test/dist';
549565
const options = { dotfiles: true, notfound: true, nojekyll: true };
@@ -553,4 +569,57 @@ describe('engine - gh-pages integration', () => {
553569
).rejects.toThrow('Git push failed');
554570
});
555571
});
572+
573+
describe('silent-swallow regression (issue #205)', () => {
574+
// gh-pages@6 absorbs errors into its returned Promise via an internal
575+
// .then(_, onRejected) handler that doesn't rethrow. The callback, however,
576+
// still fires with the error. Engine must use the callback form so git
577+
// failures (e.g. auth errors during clone) surface as rejections.
578+
579+
it('should reject when gh-pages resolves its promise but delivers an error via callback', async () => {
580+
const authError = new Error(
581+
"fatal: Authentication failed for 'https://github.com/owner/repo.git'"
582+
);
583+
584+
ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => {
585+
if (callback) {
586+
callback(authError);
587+
}
588+
return Promise.resolve(undefined);
589+
});
590+
591+
const testDir = '/test/dist';
592+
const options = { dotfiles: true, notfound: true, nojekyll: true };
593+
594+
await expect(
595+
engine.run(testDir, options, logger)
596+
).rejects.toThrow(/Authentication failed/);
597+
});
598+
599+
it('should NOT log the success banner when publish fails via callback', async () => {
600+
const authError = new Error('fatal: Authentication failed');
601+
602+
ghpagesPublishSpy.mockImplementation((_dir: string, _opts: unknown, callback?: (error: Error | null) => void) => {
603+
if (callback) {
604+
callback(authError);
605+
}
606+
return Promise.resolve(undefined);
607+
});
608+
609+
const testLogger = new logging.Logger('test');
610+
const infoSpy = jest.spyOn(testLogger, 'info');
611+
612+
const testDir = '/test/dist';
613+
const options = { dotfiles: true, notfound: true, nojekyll: true };
614+
615+
await expect(
616+
engine.run(testDir, options, testLogger)
617+
).rejects.toThrow();
618+
619+
const bannerCall = infoSpy.mock.calls.find(
620+
([msg]) => typeof msg === 'string' && msg.includes('Successfully published')
621+
);
622+
expect(bannerCall).toBeUndefined();
623+
});
624+
});
556625
});

src/engine/engine.spec.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -279,11 +279,19 @@ describe('engine', () => {
279279
ghpagesPublishSpy.mockRestore();
280280
});
281281

282+
// engine uses the callback form of gh-pages.publish() — see #205
283+
const mockPublishCallback = (error: Error | null) =>
284+
(_dir: unknown, _opts: unknown, callback?: (err: Error | null) => void) => {
285+
if (callback) {
286+
callback(error);
287+
}
288+
return Promise.resolve(undefined);
289+
};
290+
282291
it('should reject when gh-pages.publish rejects with error', async () => {
283292
const publishError = new Error('Git push failed: permission denied');
284293

285-
// gh-pages v5+ properly rejects with errors
286-
ghpagesPublishSpy.mockRejectedValue(publishError);
294+
ghpagesPublishSpy.mockImplementation(mockPublishCallback(publishError));
287295

288296
const testDir = '/test/dist';
289297
const options = { dotfiles: true, notfound: true, nojekyll: true };
@@ -296,7 +304,7 @@ describe('engine', () => {
296304
it('should preserve error message through rejection', async () => {
297305
const detailedError = new Error('Remote url mismatch. Expected https://github.com/user/repo.git but got https://github.com/other/repo.git');
298306

299-
ghpagesPublishSpy.mockRejectedValue(detailedError);
307+
ghpagesPublishSpy.mockImplementation(mockPublishCallback(detailedError));
300308

301309
const testDir = '/test/dist';
302310
const options = { dotfiles: true, notfound: true, nojekyll: true };
@@ -309,7 +317,7 @@ describe('engine', () => {
309317
it('should reject with authentication error from gh-pages', async () => {
310318
const authError = new Error('Authentication failed: Invalid credentials');
311319

312-
ghpagesPublishSpy.mockRejectedValue(authError);
320+
ghpagesPublishSpy.mockImplementation(mockPublishCallback(authError));
313321

314322
const testDir = '/test/dist';
315323
const options = { dotfiles: true, notfound: true, nojekyll: true };
@@ -320,8 +328,7 @@ describe('engine', () => {
320328
});
321329

322330
it('should resolve successfully when gh-pages.publish resolves', async () => {
323-
// gh-pages v5+ resolves the Promise on success
324-
ghpagesPublishSpy.mockResolvedValue(undefined);
331+
ghpagesPublishSpy.mockImplementation(mockPublishCallback(null));
325332

326333
const testDir = '/test/dist';
327334
const options = { dotfiles: true, notfound: true, nojekyll: true };

src/engine/engine.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,17 @@ async function publishViaGhPages(
197197
nojekyll: options.nojekyll
198198
};
199199

200-
// gh-pages v5+ fixed the Promise bug where errors didn't reject properly
201-
// We can now safely await the promise directly
202-
await ghPages.publish(dir, ghPagesOptions);
200+
// gh-pages@6 silently absorbs errors via its internal .then(_, onRejected)
201+
// handler (and returns undefined from some early-exit paths, upstream #465).
202+
// The callback always fires with the error, so we bridge that to a rejection.
203+
// See issue #205.
204+
await new Promise<void>((resolve, reject) => {
205+
ghPages.publish(dir, ghPagesOptions, (error: Error | null) => {
206+
if (error) {
207+
reject(error);
208+
return;
209+
}
210+
resolve();
211+
});
212+
});
203213
}

src/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "angular-cli-ghpages",
3-
"version": "3.0.2",
3+
"version": "3.0.3",
44
"description": "Deploy your Angular app to GitHub Pages or Cloudflare Pages directly from the Angular CLI (ng deploy)",
55
"main": "index.js",
66
"types": "index.d.ts",

0 commit comments

Comments
 (0)