Skip to content

Commit ab844ca

Browse files
authored
Enhance deploy output logs for token access (#2862)
Add specific authentication failure messages Add testcases and update documentation
1 parent 8f368b3 commit ab844ca

File tree

3 files changed

+185
-6
lines changed

3 files changed

+185
-6
lines changed

docs/userGuide/deployingTheSite.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,33 @@ jobs:
121121
<box type="info">
122122
123123
The sample `deploy.yml` workflow above uses the [default GitHub Token secret](https://docs.github.com/en/actions/reference/authentication-in-a-workflow) that is generated automatically for each GitHub Actions workflow. You may also use a [GitHub Personal Access Token](#generating-a-github-personal-access-token) in place of the default GitHub Token.
124+
125+
Note that **Cross-repository deployments require a Personal Access Token (PAT)**, as the built-in `GITHUB_TOKEN` is scoped to the repository that triggered the workflow and **cannot push to a different repository**. If your `site.json` specifies a `deploy.repo` that differs from the repository running the workflow, the deploy will fail with an authentication error.
126+
127+
To deploy to a different repository:
128+
1. [Generate a PAT](#generating-a-github-personal-access-token) with **Contents: Read and Write** permission
129+
(for fine-grained tokens), or **repo** scope (for classic tokens).
130+
1. Store it as a repository secret (e.g. `PAT_TOKEN`) in the repository running the workflow.
131+
1. In your workflow file, you can map the secret to an environment variable and provide its name to MarkBind:
132+
- **Default Option:** Map the secret to `GITHUB_TOKEN` and use `markbind deploy --ci`.
133+
```yml
134+
env:
135+
GITHUB_TOKEN: {% raw %}${{ secrets.PAT_TOKEN }}{% endraw %}
136+
run: markbind deploy --ci
137+
```
138+
- **Custom Option:** Map the secret to an environment variable of your choice (e.g., `MY_TOKEN`) and pass its name to the `--ci` flag.
139+
```yml
140+
env:
141+
MY_TOKEN: {% raw %}${{ secrets.PAT_TOKEN }}{% endraw %}
142+
run: markbind deploy --ci MY_TOKEN
143+
```
144+
145+
<box type="tip">
146+
147+
If you see `markbind deploy` reporting success but your cross-repository site is not updated, check that you are not using the default `GITHUB_TOKEN` for a cross-repository deployment. MarkBind will log a warning if it detects this situation.
148+
149+
</box>
150+
124151
</box>
125152

126153
Once you have created the file, commit and push the file to your repo.

packages/core/src/Site/SiteDeployManager.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ export class SiteDeployManager {
9090
process.env.CACHE_DIR = cacheDirectory;
9191
}
9292

93+
let usingDefaultGithubToken = false;
9394
if (ciTokenVar) {
94-
const ciToken = _.isBoolean(ciTokenVar) ? 'GITHUB_TOKEN' : ciTokenVar;
95+
const ciToken = _.isString(ciTokenVar) ? (ciTokenVar as string) : 'GITHUB_TOKEN';
9596
if (!process.env[ciToken]) {
9697
throw new Error(`The environment variable ${ciToken} does not exist.`);
9798
}
@@ -117,6 +118,31 @@ export class SiteDeployManager {
117118
process.env.CACHE_DIR = path.join(process.env.GITHUB_WORKSPACE || '.cache');
118119
repoSlug = SiteDeployManager.extractRepoSlug(options.repo, process.env.GITHUB_REPOSITORY);
119120

121+
// Visit https://docs.github.com/en/authentication/keeping-your-account-and-data-secure
122+
// /about-authentication-to-github#about-authentication-to-github
123+
// for more information about the different token formats.
124+
const isBuiltInFormat = githubToken.startsWith('ghs_');
125+
const isPatFormat = githubToken.startsWith('ghp_') || githubToken.startsWith('github_pat_');
126+
127+
if (isPatFormat) {
128+
usingDefaultGithubToken = false;
129+
} else if (isBuiltInFormat) {
130+
usingDefaultGithubToken = true;
131+
} else {
132+
usingDefaultGithubToken = ciToken === 'GITHUB_TOKEN';
133+
}
134+
135+
// Warn early if deploying to a different repo with the default GITHUB_TOKEN,
136+
// which is scoped only to the triggering repository.
137+
if (
138+
usingDefaultGithubToken
139+
&& repoSlug
140+
&& process.env.GITHUB_REPOSITORY
141+
&& repoSlug.toLowerCase() !== process.env.GITHUB_REPOSITORY.toLowerCase()
142+
) {
143+
SiteDeployManager.warnCrossRepoToken(repoSlug, process.env.GITHUB_REPOSITORY);
144+
}
145+
120146
options.user = {
121147
name: 'github-actions',
122148
email: 'github-actions@github.com',
@@ -139,10 +165,52 @@ export class SiteDeployManager {
139165
}
140166

141167
// Waits for the repo to be updated.
142-
await publish(basePath, options);
168+
try {
169+
await publish(basePath, options);
170+
} catch (err) {
171+
SiteDeployManager.throwIfAuthError(err, usingDefaultGithubToken);
172+
throw err;
173+
}
143174
return options;
144175
}
145176

177+
static isAuthError(errMessage: string) {
178+
const authErrorPattern = new RegExp(
179+
'\\b403\\b|Authentication failed|Permission to'
180+
+ '|could not read Username|Invalid username',
181+
'i',
182+
);
183+
return authErrorPattern.test(errMessage);
184+
}
185+
186+
static throwIfAuthError(err: unknown, usingDefaultGithubToken: boolean) {
187+
// eslint-disable-next-line lodash/prefer-lodash-typecheck
188+
const errMessage = (err instanceof Error) ? err.message : String(err);
189+
if (!SiteDeployManager.isAuthError(errMessage)) return;
190+
const hint = usingDefaultGithubToken
191+
? ('\nThis may be because the built-in GITHUB_TOKEN cannot push to a different repository.\n'
192+
+ 'Consider using a Personal Access Token (PAT) instead, and ensure it has '
193+
+ '"repo" scope (classic) or "Contents: Read and Write" (fine-grained) permissions.')
194+
: ('\nEnsure your PAT has "repo" scope (classic) or "Contents: Read and Write" (fine-grained) '
195+
+ 'permissions for the target repository.');
196+
const error = new Error(
197+
`Deployment failed due to an authentication error: ${errMessage}${hint}`,
198+
);
199+
(error as any).cause = err;
200+
throw error;
201+
}
202+
203+
static warnCrossRepoToken(repoSlug: string | undefined, currentRepo: string | undefined) {
204+
logger.warn(
205+
'Warning: You are deploying to a repository different from the one running this workflow '
206+
+ `("${repoSlug}" vs "${currentRepo}").\n`
207+
+ 'If this is the built-in GITHUB_TOKEN, it is scoped only to the triggering repository and '
208+
+ 'cannot push to other repositories.\n'
209+
+ 'If you are using a Personal Access Token (PAT), consider using a custom environment variable '
210+
+ 'name (e.g. GH_TOKEN) to avoid this warning: markbind deploy --ci GH_TOKEN',
211+
);
212+
}
213+
146214
/**
147215
* Extract repo slug from user-specified repo URL so that we can include the access token
148216
*/

packages/core/test/unit/Site/SiteDeployManager.test.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import fs from 'fs-extra';
2+
import ghpages from 'gh-pages';
3+
import * as mockLogger from '../../../src/utils/logger.js';
24
import { SiteDeployManager, DeployOptions } from '../../../src/Site/SiteDeployManager.js';
35
import { SiteConfig } from '../../../src/Site/SiteConfig.js';
46
import { SITE_JSON_DEFAULT } from '../utils/data.js';
@@ -13,6 +15,11 @@ jest.mock('../../../src/utils/git', () => ({
1315
getRemoteBranchFile: jest.fn(() => Promise.resolve(null)),
1416
getRemoteUrl: jest.fn(() => Promise.resolve('https://github.com/mock-user/mock-repo.git')),
1517
}));
18+
jest.mock('../../../src/utils/logger', () => ({
19+
warn: jest.fn(),
20+
error: jest.fn(),
21+
info: jest.fn(),
22+
}));
1623

1724
const rootPath = '/tmp/test';
1825
const outputPath = '/tmp/test/_site';
@@ -26,17 +33,15 @@ const mockGhPages = {
2633
};
2734

2835
// Mock gh-pages publish
29-
const ghpages = require('gh-pages');
30-
31-
ghpages.publish = jest.fn((dir: string, options: DeployOptions, callback?: (err?: any) => void) => {
36+
(ghpages as any).publish = jest.fn((dir: string, options: DeployOptions, callback?: (err?: any) => void) => {
3237
mockGhPages.dir = dir;
3338
mockGhPages.options = options;
3439
if (callback) {
3540
callback();
3641
}
3742
return Promise.resolve();
3843
});
39-
ghpages.clean = jest.fn();
44+
(ghpages as any).clean = jest.fn();
4045

4146
afterEach(() => {
4247
mockFs.vol.reset();
@@ -105,6 +110,53 @@ test('SiteDeployManager should not deploy without a built site', async () => {
105110
+ 'Please build the site first before deploy.'));
106111
});
107112

113+
describe('SiteDeployManager.isAuthError', () => {
114+
test.each([
115+
['403 error', 'Request failed with status code 403'],
116+
['Authentication failed', 'Authentication failed for repo'],
117+
['Permission to', 'Permission to org/repo denied'],
118+
['could not read Username', 'could not read Username for repo'],
119+
['Invalid username', 'Invalid username or password'],
120+
])('returns true for auth error: %s', (_, message) => {
121+
expect(SiteDeployManager.isAuthError(message)).toBe(true);
122+
});
123+
124+
test('returns false for non-auth errors', () => {
125+
expect(SiteDeployManager.isAuthError('Network timeout')).toBe(false);
126+
expect(SiteDeployManager.isAuthError('branch not found')).toBe(false);
127+
});
128+
});
129+
130+
describe('SiteDeployManager.throwIfAuthError', () => {
131+
test('does not throw for non-auth errors', () => {
132+
expect(() => SiteDeployManager.throwIfAuthError(new Error('Network timeout'), false)).not.toThrow();
133+
});
134+
135+
test('throws with auth error message (no PAT hint when not using default token)', () => {
136+
expect(() => SiteDeployManager.throwIfAuthError(new Error('403 forbidden'), false))
137+
.toThrow('Deployment failed due to an authentication error: 403 forbidden');
138+
});
139+
140+
test('throws with PAT hint when using default GITHUB_TOKEN', () => {
141+
expect(() => SiteDeployManager.throwIfAuthError(new Error('Authentication failed'), true))
142+
.toThrow('GITHUB_TOKEN cannot push to a different repository');
143+
});
144+
145+
test('handles non-Error thrown values', () => {
146+
expect(() => SiteDeployManager.throwIfAuthError('403', false))
147+
.toThrow('Deployment failed due to an authentication error: 403');
148+
});
149+
});
150+
151+
describe('SiteDeployManager.warnCrossRepoToken', () => {
152+
test('logs a warning mentioning both repos', () => {
153+
SiteDeployManager.warnCrossRepoToken('other-org/other-repo', 'my-org/my-repo');
154+
expect(mockLogger.warn).toHaveBeenCalledWith(
155+
expect.stringContaining('"other-org/other-repo" vs "my-org/my-repo"'),
156+
);
157+
});
158+
});
159+
108160
describe('Site deploy with various CI environments', () => {
109161
// Keep a copy of the original environment
110162
const OLD_ENV = { ...process.env };
@@ -287,6 +339,38 @@ describe('Site deploy with various CI environments', () => {
287339
.toThrow(new Error('-c/--ci expects a GitHub repository.\n'
288340
+ `The specified repository ${invalidRepoConfig.deploy.repo} is not valid.`));
289341
});
342+
343+
test('warns when GITHUB_TOKEN is used to deploy to a different repo in GitHub Actions', async () => {
344+
process.env.GITHUB_ACTIONS = 'true';
345+
process.env.GITHUB_TOKEN = 'githubToken';
346+
process.env.GITHUB_REPOSITORY = 'my-org/my-repo';
347+
348+
const crossRepoConfig = JSON.parse(SITE_JSON_DEFAULT);
349+
crossRepoConfig.deploy.repo = 'https://github.com/other-org/other-repo.git';
350+
mockFs.vol.fromJSON({ _site: {} }, rootPath);
351+
352+
const deployManager = new SiteDeployManager(rootPath, outputPath);
353+
deployManager.siteConfig = crossRepoConfig as SiteConfig;
354+
355+
await deployManager.deploy(true);
356+
expect(mockLogger.warn).toHaveBeenCalledWith(
357+
expect.stringContaining('"other-org/other-repo" vs "my-org/my-repo"'),
358+
);
359+
});
360+
361+
test('does not warn when GITHUB_TOKEN deploys to the same repo', async () => {
362+
process.env.GITHUB_ACTIONS = 'true';
363+
process.env.GITHUB_TOKEN = 'githubToken';
364+
process.env.GITHUB_REPOSITORY = 'GENERIC_USER/GENERIC_REPO';
365+
366+
mockFs.vol.fromJSON({ _site: {} }, rootPath);
367+
368+
const deployManager = new SiteDeployManager(rootPath, outputPath);
369+
deployManager.siteConfig = JSON.parse(SITE_JSON_DEFAULT) as SiteConfig;
370+
371+
await deployManager.deploy(true);
372+
expect(mockLogger.warn).not.toHaveBeenCalled();
373+
});
290374
});
291375

292376
describe('SiteDeployManager URL construction utilities', () => {

0 commit comments

Comments
 (0)