Skip to content

Commit

Permalink
feat: add createDummyNupkg
Browse files Browse the repository at this point in the history
Separate from dotnetHelpers to avoid circular dependency.

fixes #406 `tokenCanWritePackages` should authenticate via GPR's NuGet API and `dotnet nuget push` cli
  • Loading branch information
BinToss committed Jun 7, 2024
1 parent f86562a commit ed67d06
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 29 deletions.
1 change: 1 addition & 0 deletions src/dotnet.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * as createDummyNupkg from './dotnet/createDummyNupkg.js'
export * as dotnetGHPR from "./dotnet/dotnetGHPR.js"
export * as dotnetGLPR from "./dotnet/dotnetGLPR.js"
export * as dotnetHelpers from "./dotnet/dotnetHelpers.js"
Expand Down
30 changes: 30 additions & 0 deletions src/dotnet/createDummyNupkg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { execSync, type ExecSyncOptionsWithStringEncoding } from 'node:child_process';
import { existsSync, unlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { dirSync } from 'tmp';

export function createDummyNupkg(): string {
const dirResult = dirSync({ unsafeCleanup: true });
const dummyPkgFullPath: string = join(dirResult.name, 'DUMMY.1.0.0.nupkg');

// delete old and possibly-poisoned nupkg
if (existsSync(dummyPkgFullPath))
unlinkSync(dummyPkgFullPath);

const options: ExecSyncOptionsWithStringEncoding = {
cwd: dirResult.name,
encoding: 'utf8'
};

execSync('dotnet new console --name DUMMY', options);
const packOut = execSync(`dotnet pack DUMMY --configuration Release --output ${dirname(dummyPkgFullPath)}`, options);

const createdLine = packOut.replace('\r', '')
.split('\n')
.find(line => line.includes('Successfully created package'))?.trim();

if (!existsSync(dummyPkgFullPath))
throw new Error(`The dummy nupkg was created, but could not be found at ${dummyPkgFullPath}. See '${createdLine}'.`);

return dummyPkgFullPath;
}
58 changes: 42 additions & 16 deletions src/dotnet/dotnetGHPR.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import { ok } from 'node:assert/strict';
import { notDeepStrictEqual, ok } from 'node:assert/strict';
import type { NuGetRegistryInfo } from './dotnetHelpers.js';
import { getEnvVarValue } from '../envUtils.js';
import { exec, type ExecException } from 'node:child_process';
import { createDummyNupkg } from './createDummyNupkg.js';
import { promisify } from 'node:util';

/**
* @todo support custom base URL for private GitHub instances
* @param tokenEnvVar The name of the environment variable containing the NUGET token
* @returns `true` if the token is
* @returns `true` if the token can be used to push nupkg to the given Nuget registry
* @throws
* - TypeError: The environment variable ${tokenEnvVar} is undefined!
* - Error:
* - The value of the token in ${tokenEnvVar} begins with 'github_pat_' which means it's a Fine-Grained token. At the time of writing, GitHub Fine-Grained tokens cannot push packages. If you believe this is statement is outdated, report the issue at https://github.com/halospv3/hce.shared/issues/new. For more information, see https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-nuget-registry.
* - The GitHub API response header lacked "x-oauth-scopes". This indicates the token we provided is not a workflow token nor a Personal Access Token (classic) and can never have permission to push packages.
*/
export async function tokenCanWritePackages(tokenEnvVar: string) {
export async function tokenCanWritePackages(tokenEnvVar: string, url?: string) {
/* double-check the token exists */
const info = isTokenDefined(tokenEnvVar);
ok(info.isDefined)

if (url === undefined) {
console.debug(`tokenCanWritePackages was called without a NuGet Source URL. Defaulting to use ${`${nugetGitHubUrlBase}/\${GITHUB_REPOSITORY_OWNER}/index.json`} where GITHUB_REPOSITORY_OWNER is '${getOwner()}'`)
url = getNugetGitHubUrl();
}

notDeepStrictEqual(url, undefined);
notDeepStrictEqual(url, '');

if (info.fallback)
tokenEnvVar = info.fallback;

Expand All @@ -27,18 +37,33 @@ export async function tokenCanWritePackages(tokenEnvVar: string) {
if (tokenValue.startsWith('github_pat_'))
throw new Error(`The value of the token in ${tokenEnvVar} begins with 'github_pat_' which means it's a Fine-Grained token. At the time of writing, GitHub Fine-Grained tokens cannot push packages. If you believe this is statement is outdated, report the issue at https://github.com/halospv3/hce.shared/issues/new. For more information, see https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-nuget-registry.`)

// CJS compatibility - import { request } from '@octokit/request
const request = (await import('@octokit/request')).request;
const response = await request('GET /', {
headers: {
authorization: `Bearer ${tokenValue}`
}
});
const scopes = response.headers['x-oauth-scopes'];
if (scopes)
return scopes.includes('write:packages')
const dummyNupkgPath = createDummyNupkg();
const promiseExec = promisify(exec);

try {
const pushResult = await promiseExec(`dotnet nuget push ${dummyNupkgPath} --source ${url} --api-key ${tokenValue} --skip-duplicate`, { encoding: 'utf8' })
const errNewline = pushResult.stderr.includes('\r\n') ? '\r\n' : pushResult.stdout.includes('\r') ? '\r' : '\n';

throw new Error('GitHub API response header lacked "x-oauth-scopes". This indicates the token we provided is not a workflow token nor a Personal Access Token (classic) and can never have permission to push packages.')
// if any *lines* start with "error: " or "Error: ", log stderr
const errorCount = pushResult.stderr.split(errNewline ?? '\n').filter(line => line.trim().startsWith('error: ') || line.trim().startsWith('Error: ')).length;
if (errorCount > 0)
console.error(pushResult.stderr);

// if any lines start with "warn : ", log stdout
const warningCount = pushResult.stdout.split(errNewline ?? '\n').filter(line => line.trim().startsWith('warn : ')).length;
if (warningCount > 0)
console.warn(pushResult.stdout);

const hasAuthError = pushResult.stderr.includes('401 (Unauthorized)');

// return true is no lines contain error indicators.
return errorCount === 0 && hasAuthError === false;
}
catch (err) {
const stdout = (err as ExecException).stdout ?? '';
console.error((err as ExecException).stack + '\n' + stdout.split('Usage: dotnet nuget push')[0]);
return false;
}
}

/** returns the value of GITHUB_REPOSITORY_OWNER */
Expand All @@ -55,6 +80,7 @@ export function getNugetGitHubUrl() {
const owner = getOwner();
if (owner)
return `${nugetGitHubUrlBase}/${owner}/index.json`;
console.warn('GITHUB_REPOSITORY_OWNER is undefined! Default NuGet source for GitHub is unavailable.');
return undefined;
}

Expand Down Expand Up @@ -113,7 +139,7 @@ export async function getGithubNugetRegistryPair(
if (_isTokenDefinedInfo.fallback)
tokenEnvVar = _isTokenDefinedInfo.fallback;
try {
canTokenWritePackages = await tokenCanWritePackages(tokenEnvVar);
canTokenWritePackages = await tokenCanWritePackages(tokenEnvVar, url);
}
catch (err) {
if (err instanceof Error)
Expand Down
2 changes: 2 additions & 0 deletions tests/dotnet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { describe } from "node:test"
// simple "symbol exists" checks only. More specific checks in each module's respective test file.

describe('dotnet re-export checks', () => {
ok("createDummyNupkg" in dotnet.createDummyNupkg)

ok("getGithubNugetRegistryPair" in dotnet.dotnetGHPR)
ok("nugetGitHubUrl" in dotnet.dotnetGHPR)
ok("nugetGitHubUrlBase" in dotnet.dotnetGHPR)
Expand Down
12 changes: 12 additions & 0 deletions tests/dotnet/createDummyNupkg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createDummyNupkg } from '@halospv3/hce.shared-config/dotnet/createDummyNupkg';
import { strictEqual } from 'node:assert';
import { existsSync } from 'node:fs';
import { describe, it } from 'node:test';

await describe('createDummyNupkg', () => {
const dummyNupkgPath = createDummyNupkg();

it('returns a path that exists', () => {
strictEqual(existsSync(dummyNupkgPath), true);
})
});
46 changes: 33 additions & 13 deletions tests/dotnet/dotnetGHPR.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@halospv3/hce.shared-config/dotnet/dotnetGHPR';
import type { NuGetRegistryInfo } from '@halospv3/hce.shared-config/dotnet/dotnetHelpers';
import { getEnv, getEnvVarValue } from '@halospv3/hce.shared-config/envUtils';
import { deepStrictEqual, notStrictEqual, strictEqual } from 'node:assert';
import { deepStrictEqual, notStrictEqual, ok, strictEqual } from 'node:assert';
import { env } from 'node:process';
import { beforeEach, describe, it, todo } from 'node:test';
import { configDotenv, type DotenvConfigOptions } from 'dotenv';
Expand All @@ -24,18 +24,38 @@ if (!existsSync(dotenvPath))
const dotenvOptions: DotenvConfigOptions = { path: dotenvPath }

await describe('dotnetGHPR', async () => {
// await it(`nugetGitHubUrl is defined`, { signal: c.signal }, () => {
// const { GITHUB_REPOSITORY_OWNER } = env;
// if (GITHUB_REPOSITORY_OWNER) {
// ok(nugetGitHubUrl);
// strictEqual(typeof nugetGitHubUrl, "string", `nugetGitHubUrl should be a string when GITHUB_REPOSITORY_OWNER is in process environment! It is "${typeof nugetGitHubUrl}"`);
// strictEqual(typeof nugetGitHubUrlBase, "string", `nugetGitHubUrlBase should be a string! It is "${typeof nugetGitHubUrlBase}"`);
// ok(nugetGitHubUrl.startsWith(nugetGitHubUrlBase));
// }
// else {
// strictEqual(nugetGitHubUrl, undefined);
// }
// });
await describe('tokenCanWritePackages', async () => {
await it('returns true when GITHUB_TOKEN is valid and GITHUB_REPOSITORY_OWNER is defined', async (t) => {
if (getEnvVarValue('GITHUB_TOKEN')?.startsWith('g') !== true)
t.skip('GITHUB_TOKEN is unavailable for testing');
else if (!getEnvVarValue('GITHUB_REPOSITORY_OWNER'))
t.skip('GITHUB_REPOSITORY_OWNER is unavailable for testing.')
else {
const url = getNugetGitHubUrl();
ok(url);

const canWrite = await tokenCanWritePackages('GITHUB_TOKEN', url)
ok(canWrite);
}
})

await it('returns false when GITHUB_TOKEN is invalid', async () => {
const warnBak = console.warn;
try {
console.warn = () => { return };
const url = getNugetGitHubUrl();
ok(url);

const TOKEN_CANNOT_WRITE = 'TOKEN_CANNOT_WRITE';
getEnv(undefined, { TOKEN_CANNOT_WRITE })
const canWrite = await tokenCanWritePackages(TOKEN_CANNOT_WRITE);
strictEqual(canWrite, false);
}
finally {
console.warn = warnBak;
}
});
});

await describe('getNugetGitHubUrl', async () => {
await it('returns string when GITHUB_REPOSITORY_OWNER is defined', () => {
Expand Down

0 comments on commit ed67d06

Please sign in to comment.