diff --git a/.github/update-release-branch.py b/.github/update-release-branch.py index 90e0592593..0c749e7b22 100644 --- a/.github/update-release-branch.py +++ b/.github/update-release-branch.py @@ -16,12 +16,23 @@ """ # NB: This exact commit message is used to find commits for reverting during backports. -# Changing it requires a transition period where both old and new versions are supported. +# Changing it requires a transition period where both old and new versions are supported. BACKPORT_COMMIT_MESSAGE = 'Update version and changelog for v' # Name of the remote ORIGIN = 'origin' +# Environment variables to check for a GitHub API token. +TOKEN_ENVIRONMENT_VARIABLES = ('GH_TOKEN', 'GITHUB_TOKEN') + +# Gets a GitHub API token from one of the supported environment variables. +def get_github_token(): + for variable_name in TOKEN_ENVIRONMENT_VARIABLES: + token = os.environ.get(variable_name, '').strip() + if token: + return token + raise Exception('Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN.') + # Runs git with the given args and returns the stdout. # Raises an error if git does not exit successfully (unless passed # allow_non_zero_exit_code=True). @@ -270,12 +281,6 @@ def update_changelog(version): def main(): parser = argparse.ArgumentParser('update-release-branch.py') - parser.add_argument( - '--github-token', - type=str, - required=True, - help='GitHub token, typically from GitHub Actions.' - ) parser.add_argument( '--repository-nwo', type=str, @@ -313,7 +318,7 @@ def main(): target_branch = args.target_branch is_primary_release = args.is_primary_release - repo = Github(args.github_token).get_repo(args.repository_nwo) + repo = Github(get_github_token()).get_repo(args.repository_nwo) # the target branch will be of the form releases/vN, where N is the major version number target_branch_major_version = target_branch.strip('releases/v') diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 147689ace0..40d25e2163 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -64,11 +64,12 @@ jobs: - name: Update current release branch if: github.event_name == 'workflow_dispatch' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo SOURCE_BRANCH=${REF_NAME} echo TARGET_BRANCH=releases/${MAJOR_VERSION} python .github/update-release-branch.py \ - --github-token ${{ secrets.GITHUB_TOKEN }} \ --repository-nwo ${{ github.repository }} \ --source-branch '${{ env.REF_NAME }}' \ --target-branch 'releases/${{ env.MAJOR_VERSION }}' \ @@ -107,11 +108,12 @@ jobs: - uses: ./.github/actions/release-initialise - name: Update older release branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo SOURCE_BRANCH=${SOURCE_BRANCH} echo TARGET_BRANCH=${TARGET_BRANCH} python .github/update-release-branch.py \ - --github-token ${{ secrets.GITHUB_TOKEN }} \ --repository-nwo ${{ github.repository }} \ --source-branch ${SOURCE_BRANCH} \ --target-branch ${TARGET_BRANCH} \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2360f19f9d..b67ccb13b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ Once the mergeback and backport pull request have been merged, the release is co Since the `codeql-action` runs most of its testing through individual Actions workflows, there are over two hundred required jobs that need to pass in order for a PR to turn green. It would be too tedious to maintain that list manually. You can regenerate the set of required checks automatically by running the [sync-checks.ts](pr-checks/sync-checks.ts) script: -- At a minimum, you must provide an argument for the `--token` input. For example, `--token "$(gh auth token)"` to use the same token that `gh` uses. If no token is provided or the token has insufficient permissions, the script will fail. +- At a minimum, you must provide a token with permissions to update branch protection rules. For example, `gh auth token | pr-checks/sync-checks.ts --token-stdin` uses the same token that `gh` uses. You can also set the `GH_TOKEN` or `GITHUB_TOKEN` environment variable. If no token is provided or the token has insufficient permissions, the script will fail. - By default, the script performs a dry run and outputs information about the changes it would make to the branch protection rules. To actually apply the changes, specify the `--apply` flag. - If you run the script without any other arguments, it will retrieve the set of workflows that ran for the latest commit on `main`. - You can specify a different git ref with the `--ref` input. You will likely want to use this if you have a PR that removes or adds PR checks. For example, `--ref "some/branch/name"` to use the HEAD of the `some/branch/name` branch. diff --git a/pr-checks/sync-checks.test.ts b/pr-checks/sync-checks.test.ts index 4d49084cba..18d288582d 100644 --- a/pr-checks/sync-checks.test.ts +++ b/pr-checks/sync-checks.test.ts @@ -7,7 +7,13 @@ Tests for the sync-checks.ts script import * as assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { CheckInfo, Exclusions, Options, removeExcluded } from "./sync-checks"; +import { + CheckInfo, + Exclusions, + Options, + removeExcluded, + resolveToken, +} from "./sync-checks"; const defaultOptions: Options = { apply: false, @@ -58,3 +64,46 @@ describe("removeExcluded", async () => { assert.deepEqual(retained, expectedExactMatches); }); }); + +describe("resolveToken", async () => { + await it("reads the token from standard input", async () => { + const token = await resolveToken( + { tokenStdin: true }, + { env: {}, readStdin: async () => " stdin-token\n" }, + ); + assert.equal(token, "stdin-token"); + }); + + await it("reads the token from the GH_TOKEN environment variable", async () => { + const token = await resolveToken( + {}, + { env: { GH_TOKEN: "env-token" }, readStdin: async () => "" }, + ); + assert.equal(token, "env-token"); + }); + + await it("reads the token from the GITHUB_TOKEN environment variable", async () => { + const token = await resolveToken( + {}, + { env: { GITHUB_TOKEN: "env-token" }, readStdin: async () => "" }, + ); + assert.equal(token, "env-token"); + }); + + await it("rejects an empty standard input token", async () => { + await assert.rejects( + resolveToken( + { tokenStdin: true }, + { env: {}, readStdin: async () => "\n" }, + ), + /No token received on standard input/, + ); + }); + + await it("rejects missing token sources", async () => { + await assert.rejects( + resolveToken({}, { env: {}, readStdin: async () => "" }), + /Missing authentication token/, + ); + }); +}); diff --git a/pr-checks/sync-checks.ts b/pr-checks/sync-checks.ts index ef07531107..afebc5831e 100755 --- a/pr-checks/sync-checks.ts +++ b/pr-checks/sync-checks.ts @@ -15,8 +15,8 @@ import { /** Represents the command-line options. */ export interface Options { - /** The token to use to authenticate to the GitHub API. */ - token?: string; + /** Whether to read the GitHub API token from standard input. */ + tokenStdin?: boolean; /** The git ref to use the checks for. */ ref?: string; /** Whether to actually apply the changes or not. */ @@ -31,6 +31,65 @@ const codeqlActionRepo = { repo: "codeql-action", }; +/** Environment variables to check for a GitHub API token. */ +const TOKEN_ENVIRONMENT_VARIABLES = ["GH_TOKEN", "GITHUB_TOKEN"]; + +/** Represents the sources from which we can retrieve the GitHub API token. */ +interface TokenSource { + /** Environment variables to inspect. */ + env: NodeJS.ProcessEnv; + /** Reads a token from standard input. */ + readStdin: () => Promise; +} + +/** Reads the GitHub API token from standard input. */ +async function readTokenFromStdin(): Promise { + let token = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + token += chunk; + } + return token.trim(); +} + +/** Gets a GitHub API token from one of the supported environment variables. */ +function getTokenFromEnvironment(env: NodeJS.ProcessEnv): string | undefined { + for (const variableName of TOKEN_ENVIRONMENT_VARIABLES) { + const token = env[variableName]?.trim(); + if (token) { + return token; + } + } + return undefined; +} + +/** Gets the token to use to authenticate to the GitHub API. */ +export async function resolveToken( + options: Pick, + tokenSource: TokenSource = { + env: process.env, + readStdin: readTokenFromStdin, + }, +): Promise { + if (options.tokenStdin) { + const token = (await tokenSource.readStdin()).trim(); + if (token.length === 0) { + throw new Error("No token received on standard input."); + } + return token; + } + + const environmentToken = getTokenFromEnvironment(tokenSource.env); + if (environmentToken !== undefined) { + return environmentToken; + } + + throw new Error( + "Missing authentication token. Set GH_TOKEN/GITHUB_TOKEN or pipe a token " + + "to --token-stdin.", + ); +} + /** Represents a configuration of which checks should not be set up as required checks. */ export interface Exclusions { /** A list of strings that, if contained in a check name, are excluded. */ @@ -205,9 +264,10 @@ async function updateBranch( async function main(): Promise { const { values: options } = parseArgs({ options: { - // The token to use to authenticate to the API. - token: { - type: "string", + // Read the token to use to authenticate to the API from standard input. + "token-stdin": { + type: "boolean", + default: false, }, // The git ref for which to retrieve the check runs. ref: { @@ -228,16 +288,16 @@ async function main(): Promise { strict: true, }); - if (options.token === undefined) { - throw new Error("Missing --token"); - } + const token = await resolveToken({ + tokenStdin: options["token-stdin"], + }); console.info( `Oldest supported major version is: ${OLDEST_SUPPORTED_MAJOR_VERSION}`, ); // Initialise the API client. - const client = getApiClient(options.token); + const client = getApiClient(token); // Find the check runs for the specified `ref` that we will later set as the required checks // for the main and release branches.