From ee431b9c7234fc2f7c30fce3d0498c0403ecb7ab Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 9 Oct 2026 11:55:45 +0200 Subject: [PATCH 1/3] fix: keep .nojekyll and CNAME on redeploy The cleanup hook from #207 removes every tracked file that is not in dist. gh-pages writes .nojekyll and CNAME into its clone, not into dist, so a redeploy removed the .nojekyll that gh-pages had just created and GitHub Pages ran Jekyll again. CNAME survived only because gh-pages' own remove step unstages non-dot files first. The hook now keeps the files gh-pages generates for the active options. A leftover .nojekyll is still removed when nojekyll is disabled. CI deploys every Angular version twice and checks for .nojekyll. --- .github/workflows/main.yml | 10 ++++ src/engine/engine.gh-pages-cleanup.spec.ts | 56 ++++++++++++++++++++ src/engine/engine.prepare-options-helpers.ts | 9 +++- src/engine/engine.ts | 16 +++++- 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f7dddf..77518af 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -86,7 +86,9 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + npx ng deploy --message="second deploy" cd /tmp/remote-18.git && git branch | grep gh-pages + git ls-tree --name-only gh-pages | grep -x .nojekyll echo "Angular 18: build + deploy successful" - name: Test Angular 19 @@ -99,7 +101,9 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + npx ng deploy --message="second deploy" cd /tmp/remote-19.git && git branch | grep gh-pages + git ls-tree --name-only gh-pages | grep -x .nojekyll echo "Angular 19: build + deploy successful" - name: Test Angular 20 @@ -112,7 +116,9 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + npx ng deploy --message="second deploy" cd /tmp/remote-20.git && git branch | grep gh-pages + git ls-tree --name-only gh-pages | grep -x .nojekyll echo "Angular 20: build + deploy successful" - name: Test Angular 21 @@ -125,7 +131,9 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + npx ng deploy --message="second deploy" cd /tmp/remote-21.git && git branch | grep gh-pages + git ls-tree --name-only gh-pages | grep -x .nojekyll echo "Angular 21: build + deploy successful" - name: Test Angular 22 @@ -138,5 +146,7 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy + npx ng deploy --message="second deploy" cd /tmp/remote-22.git && git branch | grep gh-pages + git ls-tree --name-only gh-pages | grep -x .nojekyll echo "Angular 22: build + deploy successful" diff --git a/src/engine/engine.gh-pages-cleanup.spec.ts b/src/engine/engine.gh-pages-cleanup.spec.ts index c734023..d355162 100644 --- a/src/engine/engine.gh-pages-cleanup.spec.ts +++ b/src/engine/engine.gh-pages-cleanup.spec.ts @@ -153,6 +153,62 @@ describe('end-to-end cleanup regression (issue #204, real git)', () => { expect(tree).not.toContain('stale.html'); }, 30_000); + it('keeps .nojekyll and CNAME on a redeploy', async () => { + const deploy = () => engine.run( + distDir, + { + repo: bareRepoPath, + branch: 'gh-pages', + dotfiles: true, + notfound: false, + nojekyll: true, + cname: 'example.org', + name: 'Test', + email: 'test@test.com', + message: 'test deploy' + }, + new logging.NullLogger() + ); + + await deploy(); + await fs.writeFile(path.join(distDir, 'index.html'), 'second deploy\n'); + await deploy(); + + const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) + .split('\n') + .filter(Boolean) + .sort(); + + expect(tree).toEqual(['.nojekyll', 'CNAME', 'index.html']); + expect(git('show gh-pages:CNAME', { cwd: bareRepoPath })).toBe('example.org'); + }, 30_000); + + it('removes a leftover .nojekyll when nojekyll is disabled', async () => { + const deploy = (nojekyll: boolean) => engine.run( + distDir, + { + repo: bareRepoPath, + branch: 'gh-pages', + dotfiles: true, + notfound: false, + nojekyll, + name: 'Test', + email: 'test@test.com', + message: 'test deploy' + }, + new logging.NullLogger() + ); + + await deploy(true); + await deploy(false); + + const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) + .split('\n') + .filter(Boolean); + + expect(tree).toEqual(['index.html']); + }, 30_000); + it('baseline: without our hook, gh-pages alone leaks dotfiles and submodule gitlinks (demonstrates the upstream bug)', async () => { // Call gh-pages.publish() directly — no engine.run(), no beforeAdd hook. // This is exactly what angular-cli-ghpages v3 did before our fix. diff --git a/src/engine/engine.prepare-options-helpers.ts b/src/engine/engine.prepare-options-helpers.ts index 46c8910..c32f4e1 100644 --- a/src/engine/engine.prepare-options-helpers.ts +++ b/src/engine/engine.prepare-options-helpers.ts @@ -346,6 +346,10 @@ export async function collectDistFiles( * indexed (`git ls-files -z`), diff against the set of files in our dist, and * `git rm` the leftovers. `git rm` correctly handles submodule gitlinks too. * + * `generatedFiles` lists files that gh-pages itself writes into the clone + * (`.nojekyll`, `CNAME`). They never exist in dist, so they are kept explicitly; + * otherwise a redeploy would remove the `.nojekyll` that gh-pages just created. + * * Upstream fix: tschaub/gh-pages#612 (merged 2025-08-09, unreleased as of * gh-pages@6.3.0). When a release containing that PR lands, this hook becomes * redundant and can be removed. @@ -353,13 +357,14 @@ export async function collectDistFiles( export function createCleanupBeforeAddHook( distDir: string, dotfiles: boolean, - logger: logging.LoggerApi + logger: logging.LoggerApi, + generatedFiles: string[] = [] ): (git: GhPagesGit) => Promise { return async (git) => { const distFiles = await collectDistFiles(distDir, dotfiles); await git.exec('ls-files', '-z'); const tracked = (git.output || '').split('\0').filter(Boolean); - const toRemove = tracked.filter((f) => !distFiles.has(f)); + const toRemove = tracked.filter((f) => !distFiles.has(f) && !generatedFiles.includes(f)); if (toRemove.length === 0) { return; } diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 4fde957..3137e83 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -153,6 +153,20 @@ async function createNotFoundFile( // CNAME and .nojekyll files are now handled by gh-pages v6+ via the cname/nojekyll options // Previously we created these files ourselves before publishing, but gh-pages PR #533 added native support +/** + * Files that gh-pages writes into its clone (not into dist) for the given options. + */ +function generatedFiles(options: PreparedOptions): string[] { + const files: string[] = []; + if (options.nojekyll) { + files.push('.nojekyll'); + } + if (options.cname) { + files.push('CNAME'); + } + return files; +} + async function publishViaGhPages( ghPages: GHPages, dir: string, @@ -206,7 +220,7 @@ async function publishViaGhPages( // Workaround for gh-pages#612 (unreleased in v6.3.0): clean up leftover // gh-pages branch files (dotfiles, submodule gitlinks) that the broken // remove step misses. Skipped when the user opts into additive mode. - beforeAdd: options.add ? undefined : createCleanupBeforeAddHook(dir, options.dotfiles, logger) + beforeAdd: options.add ? undefined : createCleanupBeforeAddHook(dir, options.dotfiles, logger, generatedFiles(options)) }; // gh-pages@6 silently absorbs errors via its internal .then(_, onRejected) From bcc2f7a86739f07594801864ce1f5e2f3cd2fafc Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 9 Oct 2026 11:55:45 +0200 Subject: [PATCH 2/3] chore: release 3.1.2 --- src/package-lock.json | 4 ++-- src/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/package-lock.json b/src/package-lock.json index 8e6d7e9..f1c6656 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "angular-cli-ghpages", - "version": "3.1.1", + "version": "3.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "angular-cli-ghpages", - "version": "3.1.1", + "version": "3.1.2", "license": "MIT", "dependencies": { "@angular-devkit/architect": ">=0.1800.0 <0.2300.0", diff --git a/src/package.json b/src/package.json index 8662572..ff12760 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "angular-cli-ghpages", - "version": "3.1.1", + "version": "3.1.2", "description": "Deploy your Angular app to GitHub Pages or Cloudflare Pages directly from the Angular CLI (ng deploy)", "main": "index.js", "types": "index.d.ts", From 64e914472c70a34087c6eb7e438b8e2ddfbe22c3 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 9 Oct 2026 20:33:37 +0200 Subject: [PATCH 3/3] fix: address review findings for the redeploy fix - CI checks the exact file name (git cat-file -e) and asserts that the second deploy created a commit - generatedFiles is a required parameter of createCleanupBeforeAddHook - unit tests for the hook with a mocked git - comments describe the current behaviour --- .github/workflows/main.yml | 30 ++++++++----- .../engine.prepare-options-helpers.spec.ts | 43 +++++++++++++++++++ src/engine/engine.prepare-options-helpers.ts | 2 +- src/engine/engine.ts | 7 ++- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 77518af..0ca8eb9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -86,9 +86,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy - npx ng deploy --message="second deploy" + echo second > dist/test-app-18/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-18.git && git branch | grep gh-pages - git ls-tree --name-only gh-pages | grep -x .nojekyll + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 18: build + deploy successful" - name: Test Angular 19 @@ -101,9 +103,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy - npx ng deploy --message="second deploy" + echo second > dist/test-app-19/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-19.git && git branch | grep gh-pages - git ls-tree --name-only gh-pages | grep -x .nojekyll + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 19: build + deploy successful" - name: Test Angular 20 @@ -116,9 +120,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy - npx ng deploy --message="second deploy" + echo second > dist/test-app-20/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-20.git && git branch | grep gh-pages - git ls-tree --name-only gh-pages | grep -x .nojekyll + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 20: build + deploy successful" - name: Test Angular 21 @@ -131,9 +137,11 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy - npx ng deploy --message="second deploy" + echo second > dist/test-app-21/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-21.git && git branch | grep gh-pages - git ls-tree --name-only gh-pages | grep -x .nojekyll + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 21: build + deploy successful" - name: Test Angular 22 @@ -146,7 +154,9 @@ jobs: npm link angular-cli-ghpages npx ng add angular-cli-ghpages npx ng deploy - npx ng deploy --message="second deploy" + echo second > dist/test-app-22/browser/second.txt + npx ng deploy --no-build --message="second deploy" cd /tmp/remote-22.git && git branch | grep gh-pages - git ls-tree --name-only gh-pages | grep -x .nojekyll + test "$(git rev-list --count gh-pages)" = 2 + git cat-file -e gh-pages:.nojekyll echo "Angular 22: build + deploy successful" diff --git a/src/engine/engine.prepare-options-helpers.spec.ts b/src/engine/engine.prepare-options-helpers.spec.ts index a876d6d..444c75a 100644 --- a/src/engine/engine.prepare-options-helpers.spec.ts +++ b/src/engine/engine.prepare-options-helpers.spec.ts @@ -721,4 +721,47 @@ describe('prepareOptions helpers - intensive tests', () => { } }); }); + + describe('createCleanupBeforeAddHook', () => { + let distDir: string; + + beforeEach(async () => { + distDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ghp-hook-')); + await fs.writeFile(path.join(distDir, 'index.html'), ''); + }); + + afterEach(async () => { + await fs.rm(distDir, { recursive: true, force: true }); + }); + + function createGit(tracked: string[]) { + const git: helpers.GhPagesGit = { + cwd: '/clone', + output: '', + exec: vi.fn(async () => { + git.output = tracked.join('\0'); + return git; + }), + rm: vi.fn(async () => git) + }; + return git; + } + + it('keeps generated files that are tracked but not in dist', async () => { + const git = createGit(['.nojekyll', 'CNAME', 'index.html']); + const generatedFiles = ['.nojekyll', 'CNAME']; + + await helpers.createCleanupBeforeAddHook(distDir, true, testLogger, generatedFiles)(git); + + expect(git.rm).not.toHaveBeenCalled(); + }); + + it('removes tracked files that are neither in dist nor generated', async () => { + const git = createGit(['.nojekyll', 'index.html', 'stale.html']); + + await helpers.createCleanupBeforeAddHook(distDir, true, testLogger, [])(git); + + expect(git.rm).toHaveBeenCalledWith(['.nojekyll', 'stale.html']); + }); + }); }); diff --git a/src/engine/engine.prepare-options-helpers.ts b/src/engine/engine.prepare-options-helpers.ts index c32f4e1..793d357 100644 --- a/src/engine/engine.prepare-options-helpers.ts +++ b/src/engine/engine.prepare-options-helpers.ts @@ -358,7 +358,7 @@ export function createCleanupBeforeAddHook( distDir: string, dotfiles: boolean, logger: logging.LoggerApi, - generatedFiles: string[] = [] + generatedFiles: string[] ): (git: GhPagesGit) => Promise { return async (git) => { const distFiles = await collectDistFiles(distDir, dotfiles); diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 3137e83..67f8d57 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -49,7 +49,7 @@ export async function run( await checkIfDistFolderExists(dir); await createNotFoundFile(dir, prepared, logger); - // Note: CNAME and .nojekyll files are now created by gh-pages v6+ via options + // gh-pages writes CNAME and .nojekyll into its clone (cname/nojekyll options) await publishViaGhPages(ghpages, dir, prepared, logger); if (!prepared.dryRun) { @@ -150,11 +150,10 @@ async function createNotFoundFile( } } -// CNAME and .nojekyll files are now handled by gh-pages v6+ via the cname/nojekyll options -// Previously we created these files ourselves before publishing, but gh-pages PR #533 added native support - /** * Files that gh-pages writes into its clone (not into dist) for the given options. + * gh-pages' own remove step already unstages CNAME before rewriting it; listing it + * here keeps the cleanup hook correct independently of that detail. */ function generatedFiles(options: PreparedOptions): string[] { const files: string[] = [];