Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ major version, and each entry says so when it happens.

## [Unreleased]

### Fixed

- The upload service refused every upload with "GitHub could not save the file
just now". Since 2026 GitHub issues App installation tokens in a longer,
stateless form (`ghs_<app id>_<JWT>`, about 520 characters, with `.` and `-`),
and the service only accepted the old 40-character form. It now accepts both,
using GitHub's published pattern. Submitters' sign-in tokens are unaffected.

## [1.11.0-rc.12] — 2026-10-16

Twelfth candidate. rc.11 failed closed in a deployment's update (nothing was
Expand Down
15 changes: 13 additions & 2 deletions services/uploads/lib/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,16 @@ export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
const DEFAULT_RETRY_AFTER = 60;
const MAX_RETRY_AFTER = 3600;

/** GitHub user and installation tokens: an opaque `gh?_…`/`github_pat_…` string. */
/** GitHub user tokens: an opaque `gh?_…`/`github_pat_…` string. */
export const TOKEN_PATTERN = /^[A-Za-z0-9_]{20,512}$/;

/**
* GitHub App installation tokens: the opaque `ghs_` + 36 characters, or since
* 2026 a stateless `ghs_<app id>_<JWT>` of about 520 characters with `.` and
* `-` in it. GitHub's own pattern; it gives no upper length.
*/
export const INSTALLATION_TOKEN_PATTERN = /^ghs_[A-Za-z0-9._-]{36,}$/;

/**
* A GitHub call that did not succeed.
* - `unauthorized`: 401, the token is not (or no longer) valid
Expand Down Expand Up @@ -213,7 +220,11 @@ export async function installationToken(deps, config) {
body: { repositories: [config.repo], permissions: { contents: 'write' } },
});
const expiresAt = Date.parse(minted?.expires_at ?? '');
if (typeof minted?.token !== 'string' || !TOKEN_PATTERN.test(minted.token) || !Number.isFinite(expiresAt)) {
if (
typeof minted?.token !== 'string' ||
!INSTALLATION_TOKEN_PATTERN.test(minted.token) ||
!Number.isFinite(expiresAt)
) {
throw new GitHubError('failed', 201, { message: 'token endpoint answered without a usable token' });
}
cachedToken = { key, token: minted.token, expiresAt };
Expand Down
29 changes: 29 additions & 0 deletions services/uploads/test/github.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ test('the installation token is reused until five minutes before it expires, the
assert.equal(github.calls.length, 8);
});

test('installationToken takes both forms of installation token GitHub issues', async () => {
for (const token of ['ghs_' + 'I'.repeat(36), INSTALL_TOKEN]) {
clearInstallationTokenCache();
const github = fakeGitHub({
routes: {
[`POST /app/installations/${INSTALLATION_ID}/access_tokens`]: () =>
json(201, { token, expires_at: new Date(NOW + 60 * 60 * 1000).toISOString() }),
},
});
assert.equal(await installationToken({ fetch: github, now: () => NOW }, CONFIG), token);
}
assert.ok(INSTALL_TOKEN.length > 500 && /[.-]/.test(INSTALL_TOKEN), 'the fixture is the long form');
for (const token of [
'ghs_' + 'I'.repeat(35),
`${INSTALL_TOKEN} x`,
`${INSTALL_TOKEN}\nx`,
'ghu_' + 'I'.repeat(36),
]) {
clearInstallationTokenCache();
const github = fakeGitHub({
routes: {
[`POST /app/installations/${INSTALLATION_ID}/access_tokens`]: () =>
json(201, { token, expires_at: new Date(NOW + 60 * 60 * 1000).toISOString() }),
},
});
await assert.rejects(installationToken({ fetch: github, now: () => NOW }, CONFIG), GitHubError);
}
});

test('installationToken refuses an answer without a usable token', async () => {
const github = fakeGitHub({
routes: { [`POST /app/installations/${INSTALLATION_ID}/access_tokens`]: () => json(201, { token: 'x' }) },
Expand Down
3 changes: 2 additions & 1 deletion services/uploads/test/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const SITE = 'https://acme.github.io';
export const OWNER = 'acme';
export const REPO = 'catalog-uploads';
export const USER_TOKEN = 'ghu_' + 'U'.repeat(36);
export const INSTALL_TOKEN = 'ghs_' + 'I'.repeat(36);
/** The stateless form GitHub has issued since 2026: `ghs_<app id>_<JWT>`, about 520 characters. */
export const INSTALL_TOKEN = `ghs_123456_${'eyJ' + 'I'.repeat(60)}.${'eyJ' + 'P-_'.repeat(100)}.${'S'.repeat(140)}`;
export const USER_ID = 4242;
export const INSTALLATION_ID = 77;
export const SET = 'abcdefghijklmnopqrstuvwx23';
Expand Down
Loading