diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a96fa..908adf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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__`, 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 diff --git a/services/uploads/lib/github.mjs b/services/uploads/lib/github.mjs index 0ca01a9..a4ea207 100644 --- a/services/uploads/lib/github.mjs +++ b/services/uploads/lib/github.mjs @@ -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__` 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 @@ -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 }; diff --git a/services/uploads/test/github.test.mjs b/services/uploads/test/github.test.mjs index 9d3546c..c4535dc 100644 --- a/services/uploads/test/github.test.mjs +++ b/services/uploads/test/github.test.mjs @@ -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' }) }, diff --git a/services/uploads/test/helpers.mjs b/services/uploads/test/helpers.mjs index 959b37b..42a976a 100644 --- a/services/uploads/test/helpers.mjs +++ b/services/uploads/test/helpers.mjs @@ -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__`, 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';