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
19 changes: 13 additions & 6 deletions common/config/azure-pipelines/bump-versions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,22 @@ extends:
SYSTEM_TEAMPROJECT: $(System.TeamProject)
BUILD_BUILDID: $(Build.BuildId)

# Tag this AzDO build with the bump commit SHA and emit GitHub
# pipeline variables for the agentless PostGitHubStatus stage.
- template: /common/config/azure-pipelines/templates/run-repo-toolbox.yaml@self
parameters:
Arguments: emit-github-vars-and-tag-build
DisplayName: 'Tag build and emit GitHub pipeline variables'
Arguments: emit-github-vars
DisplayName: 'Emit GitHub pipeline variables'
Name: EmitGitHubVars
Condition: and(succeeded(), eq(variables['HasChanges'], 'true'))

# Tag this AzDO build with the bump commit SHA so it can be located
# by FindBumpPipelineRunAction in the publish pipeline.
- template: /common/config/azure-pipelines/templates/run-repo-toolbox.yaml@self
parameters:
Arguments: tag-build
DisplayName: 'Tag build with bump commit SHA'
Name: TagBuild
Condition: and(succeeded(), eq(variables['HasChanges'], 'true'))

# Posts a GitHub commit status to the bump branch HEAD once both the
# BumpVersions stage and the 1ES-injected SDLSources stage have finished.
# Uses an agentless server job so no agent allocation is needed.
Expand All @@ -174,15 +181,15 @@ extends:
and(
in(dependencies.BumpVersions.result, 'Succeeded', 'SucceededWithIssues'),
in(dependencies.SDLSources.result, 'Succeeded', 'SucceededWithIssues'),
ne(dependencies.BumpVersions.outputs['BumpVersions.EmitGitHubVars.BumpSha'], '')
ne(dependencies.BumpVersions.outputs['BumpVersions.TagBuild.BumpSha'], '')
Comment thread
iclanton marked this conversation as resolved.
)
jobs:
- job: PostStatus
displayName: 'Post success status to GitHub'
pool: server
variables:
- name: BumpSha
value: $[ stageDependencies.BumpVersions.BumpVersions.outputs['EmitGitHubVars.BumpSha'] ]
value: $[ stageDependencies.BumpVersions.BumpVersions.outputs['TagBuild.BumpSha'] ]
- name: GitHubRepoSlug
value: $[ stageDependencies.BumpVersions.BumpVersions.outputs['EmitGitHubVars.GitHubRepoSlug'] ]
- name: GitHubToken
Expand Down
4 changes: 2 additions & 2 deletions common/config/azure-pipelines/spfx-esrp-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ extends:

- template: /common/config/azure-pipelines/templates/run-repo-toolbox.yaml@self
parameters:
Arguments: emit-github-vars-and-tag-build
DisplayName: 'Tag build and emit GitHub pipeline variables'
Arguments: emit-github-vars
DisplayName: 'Emit GitHub pipeline variables'
Comment thread
iclanton marked this conversation as resolved.
Name: EmitGitHubVars

- template: /common/config/azure-pipelines/templates/find-bump-pipeline-run.yaml@self
Expand Down
6 changes: 4 additions & 2 deletions tools/repo-toolbox/src/cli/ToolboxCommandLine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { CommandLineParser } from '@rushstack/ts-command-line';
import type { ITerminal } from '@rushstack/terminal';

import { CreateOrUpdatePrAction } from './actions/CreateOrUpdatePrAction';
import { EmitGitHubVarsAndTagBuildAction } from './actions/EmitGitHubVarsAndTagBuildAction';
import { EmitGitHubVarsAction } from './actions/EmitGitHubVarsAction';
import { TagBuildAction } from './actions/TagBuildAction';
import { FindBumpPipelineRunAction } from './actions/FindBumpPipelineRunAction';
import { CreateGitHubReleasesAction } from './actions/CreateGitHubReleasesAction';
import { VerifyNpmTagAction } from './actions/VerifyNpmTagAction';
Expand All @@ -22,7 +23,8 @@ export class ToolboxCommandLine extends CommandLineParser {
this.terminal = terminal;

this.addAction(new CreateOrUpdatePrAction(terminal));
this.addAction(new EmitGitHubVarsAndTagBuildAction(terminal));
this.addAction(new EmitGitHubVarsAction(terminal));
this.addAction(new TagBuildAction(terminal));
this.addAction(new FindBumpPipelineRunAction(terminal));
this.addAction(new CreateGitHubReleasesAction(terminal));
this.addAction(new VerifyNpmTagAction(terminal));
Expand Down
60 changes: 35 additions & 25 deletions tools/repo-toolbox/src/cli/actions/CreateGitHubReleasesAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,39 @@ import type { OctokitResponse, RequestError } from '@octokit/types';
import type { ITerminal } from '@rushstack/terminal';
import type { IRequiredCommandLineStringParameter } from '@rushstack/ts-command-line';
import { Async, FileSystem, type FolderItem, type IPackageJson } from '@rushstack/node-core-library';
import { CommandLineAction } from '@rushstack/ts-command-line';

import { GitHubClient } from '../../utilities/GitHubClient';
import {
GitHubClient,
type IGitHubAuthorizationHeader,
parseGitHubAuthorizationHeader
} from '../../utilities/GitHubClient';
import {
readChangelogSectionFromTgzAsync,
readPackageInfoFromTgzAsync
} from '../../utilities/PackageTgzUtilities';
import { GitHubTokenActionBase } from './GitHubTokenActionBase';
Comment thread
iclanton marked this conversation as resolved.

/**
* Creates GitHub releases (and their associated tags) for each .tgz package in a directory.
* Tags are formatted as `@scope/package_vX.Y.Z`, matching the rushstack convention.
* Release notes are populated from the corresponding CHANGELOG.md section in the package.
*/
export class CreateGitHubReleasesAction extends CommandLineAction {
export class CreateGitHubReleasesAction extends GitHubTokenActionBase<true> {
private readonly _terminal: ITerminal;
private readonly _packagesPathParameter: IRequiredCommandLineStringParameter;
private readonly _commitShaParameter: IRequiredCommandLineStringParameter;
private readonly _githubTokenParameter: IRequiredCommandLineStringParameter;
private readonly _repoSlugParameter: IRequiredCommandLineStringParameter;

public constructor(terminal: ITerminal) {
super({
actionName: 'create-github-releases',
summary: 'Creates a GitHub release for each .tgz package in a directory.',
documentation: ''
documentation:
'Creates a GitHub release and tag for each .tgz file in the specified directory. Tags are ' +
'formatted as @scope/package_vX.Y.Z. Release notes are sourced from the package CHANGELOG.md. ' +
'Versions with a pre-release suffix (e.g. -alpha.1) are marked as GitHub pre-releases. ' +
'Packages whose release already exists are skipped.',
githubTokenRequired: true
});

this._terminal = terminal;
Expand All @@ -49,15 +57,6 @@ export class CreateGitHubReleasesAction extends CommandLineAction {
required: true
});

this._githubTokenParameter = this.defineStringParameter({
parameterLongName: '--github-token',
argumentName: 'TOKEN',
environmentVariable: 'GITHUB_TOKEN',
description:
'GitHub Authorization header value for creating releases (format: `basic <base64>` as emitted by emit-github-vars-and-tag-build).',
required: true
});

this._repoSlugParameter = this.defineStringParameter({
parameterLongName: '--repo-slug',
argumentName: 'SLUG',
Expand All @@ -71,7 +70,7 @@ export class CreateGitHubReleasesAction extends CommandLineAction {
const terminal: ITerminal = this._terminal;
const packagesPath: string = this._packagesPathParameter.value;
const commitSha: string = this._commitShaParameter.value;
const authorizationHeader: string = this._githubTokenParameter.value;
const rawAuthorizationHeader: string = this._githubTokenParameter.value;
const repoSlug: string = this._repoSlugParameter.value;

const folderItems: FolderItem[] = await FileSystem.readFolderItemsAsync(packagesPath);
Expand All @@ -87,6 +86,8 @@ export class CreateGitHubReleasesAction extends CommandLineAction {
throw new Error(`No .tgz packages found in ${packagesPath}`);
}

const authorizationHeader: IGitHubAuthorizationHeader =
parseGitHubAuthorizationHeader(rawAuthorizationHeader);
const gitHubClient: GitHubClient = await GitHubClient.createGitHubClientFromTokenAndRepoSlugAsync({
authorizationHeader,
repoSlug
Expand Down Expand Up @@ -123,19 +124,28 @@ export class CreateGitHubReleasesAction extends CommandLineAction {
});
terminal.writeLine(`Created release: ${tag}`);
} catch (e: unknown) {
if (e instanceof RequestError && e.status === 422) {
const response: OctokitResponse<RequestError> | undefined = e.response as
| OctokitResponse<RequestError>
| undefined;
const responseErrors: RequestError['errors'] = response?.data?.errors;

const alreadyExists: boolean =
if (e instanceof RequestError) {
const { status, message, response } = e;
const responseData: RequestError | undefined = (
response as OctokitResponse<RequestError> | undefined
)?.data;
const responseErrors: RequestError['errors'] = responseData?.errors;

if (
Comment thread
iclanton marked this conversation as resolved.
status === 422 &&
Array.isArray(responseErrors) &&
responseErrors.some((error) => error.code === 'already_exists');

if (alreadyExists) {
responseErrors.some((error) => error.code === 'already_exists')
) {
terminal.writeLine(`Release already exists for ${tag}; skipping.`);
return;
} else {
// Newlines break the single-line ##vso logging command format.
const sanitizedMessage: string = message.replace(/[\r\n]+/g, ' ');
terminal.writeLine(
`##vso[task.logissue type=error]GitHub API error creating release ${tag}: ` +
`HTTP ${status} — ${sanitizedMessage}. ` +
(responseData ? `response data: ${JSON.stringify(responseData)}` : '')
);
}
}

Expand Down
65 changes: 65 additions & 0 deletions tools/repo-toolbox/src/cli/actions/EmitGitHubVarsAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { ITerminal } from '@rushstack/terminal';

import {
type IGitHubAuthorizationHeader,
parseGitHubAuthorizationHeader
} from '../../utilities/GitHubClient';
import { getGitHubAuthorizationHeaderAsync, getRepoSlugAsync } from '../../utilities/GitUtilities';
import { GitHubTokenActionBase } from './GitHubTokenActionBase';

/**
* Emits GitHub-related pipeline output variables for use by downstream stages.
*
* Outputs:
* - GitHubRepoSlug — e.g. "SharePoint/spfx"
* - GitHubToken — Authorization header value (secret)
*
* GitHubToken is read from the GITHUB_TOKEN environment variable if present
* (injected by the 1ES pipeline template's 'Get GitHub Token' step) and
* normalized to a full Authorization header value. Falls back to the git
* credential stored in the checkout extraheader for environments without
* the 1ES template.
*/
export class EmitGitHubVarsAction extends GitHubTokenActionBase<false> {
private readonly _terminal: ITerminal;

public constructor(terminal: ITerminal) {
super({
actionName: 'emit-github-vars',
summary: 'Emits GitHub repo slug and auth token as AzDO output variables.',
documentation:
'Reads the GitHub repository slug from the local git remote and the authorization token ' +
'from --github-token / GITHUB_TOKEN (if set) or the git checkout credential, then emits ' +
'them as GitHubRepoSlug and GitHubToken AzDO output variables for use by downstream stages.',
githubTokenRequired: false
});

this._terminal = terminal;
}

protected override async onExecuteAsync(): Promise<void> {
const terminal: ITerminal = this._terminal;
Comment thread
iclanton marked this conversation as resolved.

const repoSlug: string = await getRepoSlugAsync(terminal);
terminal.writeLine(`##vso[task.setvariable variable=GitHubRepoSlug;isOutput=true]${repoSlug}`);
terminal.writeLine(`Emitted GitHubRepoSlug: ${repoSlug}`);

const { value: rawToken, environmentVariable, longName } = this._githubTokenParameter;
let authHeader: IGitHubAuthorizationHeader;
if (rawToken) {
authHeader = parseGitHubAuthorizationHeader(rawToken);
terminal.writeLine(`Using ${environmentVariable} from environment or ${longName} as GitHub token`);
} else {
authHeader = await getGitHubAuthorizationHeaderAsync(terminal);
terminal.writeLine('Using git credential extraheader as fallback');
}

terminal.writeLine(
`##vso[task.setvariable variable=GitHubToken;isSecret=true;isOutput=true]${authHeader.header}`
);
terminal.writeLine('Emitted GitHubToken (secret)');
}
}

This file was deleted.

44 changes: 44 additions & 0 deletions tools/repo-toolbox/src/cli/actions/GitHubTokenActionBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type {
ICommandLineActionOptions,
CommandLineStringParameter,
IRequiredCommandLineStringParameter
} from '@rushstack/ts-command-line';
import { CommandLineAction } from '@rushstack/ts-command-line';

export interface IGitHubTokenActionBaseOptions<
TTokenParameterRequired extends boolean
> extends ICommandLineActionOptions {
githubTokenRequired: TTokenParameterRequired;
}

/**
* Base class for actions that need a GitHub token. Defines a `--github-token` parameter
* backed by the `GITHUB_TOKEN` environment variable (set by the 1ES pipeline template's
* 'Get GitHub Token' step). Accepts either a raw installation token (e.g. `ghs_xxx`) or a
* full Authorization header value (e.g. `basic <base64>`).
*/
export abstract class GitHubTokenActionBase<
TTokenParameterRequired extends boolean,
TTokenParameter extends CommandLineStringParameter = TTokenParameterRequired extends true
Comment thread
iclanton marked this conversation as resolved.
? IRequiredCommandLineStringParameter
: CommandLineStringParameter
> extends CommandLineAction {
protected readonly _githubTokenParameter: TTokenParameter;

protected constructor(options: IGitHubTokenActionBaseOptions<TTokenParameterRequired>) {
const { githubTokenRequired, ...otherOptions } = options;
super(otherOptions);

this._githubTokenParameter = this.defineStringParameter({
parameterLongName: '--github-token',
argumentName: 'TOKEN',
environmentVariable: 'GITHUB_TOKEN',
description:
'GitHub token. Accepts a raw installation token (e.g. `ghs_xxx`) or a full Authorization header value (e.g. `basic <base64>`).',
required: githubTokenRequired
}) as TTokenParameter;
}
}
Loading
Loading