From 2bfba10edac3ce229db8401a032b604c9c2e7cf0 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 10 Apr 2024 18:00:06 +0200 Subject: [PATCH] Add vote lifecycle workflows and scripts (#1401) (#1522) --- .github/workflows/closeVote.yml | 91 ++++++ .github/workflows/initiateNewVote.yml | 145 +++++++++ .github/workflows/watchVote.yml | 70 +++++ votes/initiateNewVote/README.md | 25 ++ votes/initiateNewVote/_EDIT_ME.yml | 45 +++ .../decryptPrivateKeyAndCloseVote.mjs | 113 ++++++++ .../initiateNewVote/extractInfoFromReadme.mjs | 19 ++ votes/initiateNewVote/generateNewVotePR.mjs | 274 ++++++++++++++++++ votes/initiateNewVote/getVoteStatus.mjs | 58 ++++ 9 files changed, 840 insertions(+) create mode 100644 .github/workflows/closeVote.yml create mode 100644 .github/workflows/initiateNewVote.yml create mode 100644 .github/workflows/watchVote.yml create mode 100644 votes/initiateNewVote/README.md create mode 100644 votes/initiateNewVote/_EDIT_ME.yml create mode 100755 votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs create mode 100644 votes/initiateNewVote/extractInfoFromReadme.mjs create mode 100755 votes/initiateNewVote/generateNewVotePR.mjs create mode 100755 votes/initiateNewVote/getVoteStatus.mjs diff --git a/.github/workflows/closeVote.yml b/.github/workflows/closeVote.yml new file mode 100644 index 00000000..49516b55 --- /dev/null +++ b/.github/workflows/closeVote.yml @@ -0,0 +1,91 @@ +name: Close vote + +on: + # Using `issue_comment` is a bit noisy, let's disable it for now. + # issue_comment: + # types: [created] + workflow_dispatch: + inputs: + pr: + description: ID of the Vote PR that contains a vote ready to be closed + required: true + type: number + +permissions: + contents: write + pull-requests: write + +jobs: + close-vote: + if: github.event.inputs.pr || + (github.event.issue.pull_request && contains(github.event.comment.body, '-----BEGIN SHAMIR KEY PART-----')) + runs-on: ubuntu-latest + steps: + - name: Get PR URL + id: pr-url + run: | + echo "URL=${{ github.event.repository.html_url }}/pull/${{ github.event.inputs.pr || github.event.issue.number }}" >> "$GITHUB_OUTPUT" + - name: Filter comments + id: comments + run: gh pr view ${{ steps.pr-url.outputs.URL }} --json + comments --jq '.comments | map(.body | select(contains("-----BEGIN + SHAMIR KEY PART-----"))) | "comments=" + tostring' >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + - name: Get PR branch + id: branch + run: gh pr view ${{ steps.pr-url.outputs.URL }} --json + headRefName --jq '"head=" + .headRefName' >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + - name: Compute number of commits in the PR + id: nb-of-commits + run: | + NB_OF_COMMITS=$(gh pr view --json commits --jq '.commits | length' "${{ steps.pr-url.outputs.URL }}") + echo "exact=$NB_OF_COMMITS" >> $GITHUB_OUTPUT + echo "minusOne=$(($NB_OF_COMMITS - 1))" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ github.token }} + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + with: + # Loading the default branch so we use the last version of the mailmap + # rather than getting stuck to when the vote PR was open. + ref: ${{ github.event.repository.default_branch }} + persist-credentials: true # we need the credentials to push the new vote branch + - name: Download nodejs/node mailmap file + run: + curl -L https://raw.githubusercontent.com/nodejs/node/main/.mailmap >> + .mailmap + - name: Configure git + run: | + git config --global user.email "github-bot@iojs.org" + git config --global user.name "Node.js GitHub Bot" + - name: Load vote branch + run: | + git fetch origin '${{ steps.branch.outputs.head }}' + git reset FETCH_HEAD --mixed + git checkout HEAD -- '${{ steps.branch.outputs.head }}' + - run: npm install @node-core/caritat + - name: Attempt closing the vote + id: vote-summary + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "markdown<<$EOF" >> "$GITHUB_OUTPUT" + ./votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs \ + --remote origin --branch "${{ steps.branch.outputs.head }}" \ + --fromCommit "FETCH_HEAD~${{ steps.nb-of-commits.outputs.minusOne }}" \ + --toCommit "FETCH_HEAD" \ + --prURL "${{ steps.pr-url.outputs.URL }}" \ + --save-markdown-summary summaryComment.md \ + --comments "$COMMENTS" --commit-json-summary >> "$GITHUB_OUTPUT" + echo "$EOF" >> "$GITHUB_OUTPUT" + env: + COMMENTS: ${{ steps.comments.outputs.comments }} + - name: Push to the PR branch + run: git push origin "HEAD:${{ steps.branch.outputs.head }}" + - name: Publish vote summary comment + run: | + gh pr comment "${{ steps.pr-url.outputs.URL }}" --body-file summaryComment.md + env: + GH_TOKEN: ${{ github.token }} + SUMMARY: ${{ steps.vote-summary.outputs.markdown }} diff --git a/.github/workflows/initiateNewVote.yml b/.github/workflows/initiateNewVote.yml new file mode 100644 index 00000000..674fc67d --- /dev/null +++ b/.github/workflows/initiateNewVote.yml @@ -0,0 +1,145 @@ +name: Initiate new vote + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - votes/initiateNewVote/_EDIT_ME.yml + push: + branches: + - initiateNewVote + +permissions: + contents: read + +jobs: + lint-vote-init-file: + if: github.event.pull_request && github.event.pull_request.draft == false + permissions: + contents: write + pull-requests: write + repository-projects: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + with: + persist-credentials: false + # If the subject is still REPLACEME, that would mean it's a PR to modify + # the sample file, not a PR initializing a vote. + - run: '! grep -q "subject: REPLACEME" votes/initiateNewVote.yml' + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 + with: + node-version: lts/* + - name: Validate YAML and ensure there are more than 1 candidate + run: + npx js-yaml votes/initiateNewVote.yml | jq '.candidates | unique | + length > 1 or error("Not enough candidates")' + - name: Change base branch + if: github.base_ref == github.event.repository.default_branch + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/git/refs \ + -f ref='refs/heads/initiateNewVote' \ + -f sha='${{ github.event.pull_request.base.sha }}' + gh pr edit ${{ github.event.pull_request.html_url }} --base 'initiateNewVote' + env: + GH_TOKEN: ${{ github.token }} + initiate-new-vote: + if: github.event.pusher + permissions: + contents: write + pull-requests: write + repository-projects: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + with: + persist-credentials: true # we need the credentials to push the new vote branch + - name: Install Node.js + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 + with: + node-version: lts/* + - name: Extract info from the pushed file + id: data + run: | + npx js-yaml votes/initiateNewVote/_EDIT_ME.yml > data.json + echo "json_data<> "$GITHUB_OUTPUT" + cat data.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + echo "branchName=votes/$(node -p 'require("./data.json")["path-friendly-id"] || crypto.randomUUID()')" >> "$GITHUB_OUTPUT" + node >> "$GITHUB_ENV" <<'EOF' + 'use strict'; + const { createHash } = require('node:crypto'); + const { candidates } = require("./data.json"); + for (let i = 0; i < candidates.length; i++) { + const delimiter = createHash('sha256').update(candidates[i], 'utf8').digest('base64'); + console.log(`__CANDIDATES_${i}<<${delimiter}`) + process.stdout.write(candidates[i]); + process.stdout.write(`\n${delimiter}\n`); + } + console.log('__CANDIDATES<> ~/.gnupg/gpg-agent.conf + allow-preset-passphrase + default-cache-ttl 60 + max-cache-ttl 50 + EOT + chmod 600 ~/.gnupg/* + chmod 700 ~/.gnupg + systemctl --user restart gpg-agent + else + gpg-agent --daemon --allow-preset-passphrase \ + --default-cache-ttl 60 --max-cache-ttl 60 + fi + - name: Generate the vote branch and PR + run: | + ./votes/initiateNewVote/generateNewVotePR.mjs \ + --remote origin \ + --github-repo-name "$GITHUB_REPOSITORY" \ + --vote-repository-path . \ + --branch "$__BRANCH" \ + --subject "$__SUBJECT" \ + ${{ env.__CANDIDATES }} \ + --shuffle-candidates "$__SHUFFLE_CANDIDATES" \ + --header-instructions "$__HEADER_INSTRUCTIONS" \ + --footer-instructions "$__FOOTER_INSTRUCTIONS" \ + --create-pull-request --pr-intro "$__PR_INTRO" + env: + GH_TOKEN: ${{ github.token }} + __BRANCH: ${{ steps.data.outputs.branchName }} + __SUBJECT: ${{ fromJSON(steps.data.outputs.json_data).subject }} + __SHUFFLE_CANDIDATES: ${{ fromJSON(steps.data.outputs.json_data).canShuffleCandidates }} + __HEADER_INSTRUCTIONS: ${{ fromJSON(steps.data.outputs.json_data).headerInstructions }} + __FOOTER_INSTRUCTIONS: ${{ fromJSON(steps.data.outputs.json_data).footerInstructions }} + __PR_INTRO: ${{ fromJSON(steps.data.outputs.json_data).prBody }} + - name: Remove initiateNewVote branch + run: | + gh api \ + --method DELETE \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/$GITHUB_REPOSITORY/git/$GITHUB_REF" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/watchVote.yml b/.github/workflows/watchVote.yml new file mode 100644 index 00000000..ef470d02 --- /dev/null +++ b/.github/workflows/watchVote.yml @@ -0,0 +1,70 @@ +name: Validate vote commit and update participation + +on: + pull_request: + types: [synchronize] + paths: [votes/**] + +concurrency: ${{ github.workflow }}--${{ github.head_ref }} +permissions: + contents: read + pull-requests: write + repository-projects: read + +jobs: + validate-commit-and-update-participation: + if: startsWith(github.head_ref, 'votes/') + runs-on: ubuntu-latest + steps: + - name: Compute number of commits in the PR + id: nb-of-commits + run: | + echo "plusOne=$((${{ github.event.pull_request.commits }} + 1))" >> $GITHUB_OUTPUT + echo "minusOne=$((${{ github.event.pull_request.commits }} - 1))" >> $GITHUB_OUTPUT + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + with: + fetch-depth: ${{ steps.nb-of-commits.outputs.plusOne }} + persist-credentials: false + - name: Download nodejs/node mailmap file + run: + curl -L https://raw.githubusercontent.com/nodejs/node/main/.mailmap >> + .mailmap + - run: npm install @node-core/caritat + - name: Get PR description + id: desc + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "markdown<<$EOF" >> "$GITHUB_OUTPUT" + gh pr view "${{ github.event.pull_request.html_url }}" --json body --jq '.body' >> "$GITHUB_OUTPUT" + echo "$EOF" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + - name: Get updated vote status + id: status + run: + echo "prStatus=$(./votes/initiateNewVote/getVoteStatus.mjs)" >> + "$GITHUB_OUTPUT"; cat "$GITHUB_OUTPUT" + env: + SUBPATH: ${{ github.head_ref }} + FIRST_COMMIT_REF: HEAD^2~${{ steps.nb-of-commits.outputs.minusOne }} + LAST_COMMIT_REF: ${{ github.event.after }} + CHECK_COMMITS_AFTER: ${{ github.event.before }} + PR_DESCRIPTION: ${{steps.desc.outputs.markdown}} + - name: Update PR description + run: + gh pr edit "${{ github.event.pull_request.html_url }}" --body "$BODY" + env: + BODY: ${{ fromJSON(steps.status.outputs.prStatus).body }} + GH_TOKEN: ${{ github.token }} + - name: Add comment if some invalid commits were found + if: fromJSON(steps.status.outputs.prStatus).hasFailures + run: + gh pr comment "${{ github.event.pull_request.html_url }}" -b + "$SUMMARY" + env: + GH_TOKEN: ${{ github.token }} + SUMMARY: + ${{ fromJSON(steps.status.outputs.prStatus).invalidCommitReason }} + - name: Mark workflow as failed if some invalid commits were found + if: fromJSON(steps.status.outputs.prStatus).hasFailures + run: 'false' diff --git a/votes/initiateNewVote/README.md b/votes/initiateNewVote/README.md new file mode 100644 index 00000000..0604ff55 --- /dev/null +++ b/votes/initiateNewVote/README.md @@ -0,0 +1,25 @@ +# Initiate a new TSC vote + +For yes/no questions, the TSC will typically use GH reactions to conduct a vote, +and use this workflow only for questions with several candidate answers, or to +e.g guarantee vote secrecy until the vote is counted. + +## From the GitHub web UI + +1. Edit the [`_EDIT_ME.yml`](./_EDIT_ME.yml) file, fill in the info related to + vote to open. +2. When committing, chose to commit to new branch and open a Pull Request to + discuss the vote terms with the whole TSC. +3. Once the PR has approvals, merge it on the `initiateNewVote` branch (GHA + should have set that as the target/base branch automatically). +4. GHA will open a new PR with the vote initiated. + +## From the CLI + +This method is not recommended. + +1. Edit the [`_EDIT_ME.yml`](./_EDIT_ME.yml) file, fill in the info related to + vote to open. +2. Commit your changes. +3. Push that to the remote `refs/heads/initiateNewVote`. +4. GHA will open a new PR with the vote initiated. diff --git a/votes/initiateNewVote/_EDIT_ME.yml b/votes/initiateNewVote/_EDIT_ME.yml new file mode 100644 index 00000000..c9fac556 --- /dev/null +++ b/votes/initiateNewVote/_EDIT_ME.yml @@ -0,0 +1,45 @@ +# To initiate a new vote, you need to open a PR modifying this file. The vote +# can start once the PR has approvals and is merged via the GitHub interface. + +# 1. Select a subject for the vote. This can be a question addressed to the TSC +# voting members. +subject: REPLACEME + +# 2. You can leave the header instructions as is, or modify them if you see fit. +headerInstructions: | + Please set a score to each proposal according to your preferences. + You should set the highest score to your favorite option. + Negative scores are allowed, only the order matters. + You can tie two or more proposals if you have no preference. + To abstain, keep all the propositions tied. + +# 3. Give a list of "candidates". Those should be answers to the subject +# question, and should leave as little room to interpretation as possible. Do +# not list candidates that don't have a champion, there should be a +# clear plan for each candidates in the event where it wins the vote; listing +# a "troll candidate" will only hurt the credibility of the voting process if +# it wins and everyone realize we have to re-take the vote because it can't +# happen. Don't hesitate to list very similar candidates, with however small +# nuances: we are using the Condorcet method to count the votes, which lets +# voters express their preference for each candidates, no matter how many +# there are. +candidates: + - TODO + - TODO + +# 4. Pass the following to false if it's important to keep the candidates in the +# order you define above. Presenting candidates in a fixed order tends to +# give an unfair advantage to the first option. +canShuffleCandidates: true + +# 5. Insert here a short description of the vote objectives and link to the +# issue it was discussed on to give the full context. +footerInstructions: | + TBD + +# 6. Optionally, insert a brief introduction for the vote PR, in the markdown format. +prBody: | + +# 7. Optionally, choose an id that will be used for the branch name as well as +# the vote folder name. If not supplied, a UUID will be used. +path-friendly-id: null diff --git a/votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs b/votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs new file mode 100755 index 00000000..e04475b6 --- /dev/null +++ b/votes/initiateNewVote/decryptPrivateKeyAndCloseVote.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { createInterface as readLines } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import countFromGit from "@node-core/caritat/countBallotsFromGit"; + +const { values: parsedArgs } = parseArgs({ + options: { + remote: { + type: "string", + describe: + "Name or URL to the remote repo. If not provided, SSH pointing to --github-repo-name will be used.", + }, + branch: { + type: "string", + describe: "Name of the branch where the vote takes place", + }, + mailmap: { + type: "string", + describe: "Path to the mailmap file (if any)", + }, + fromCommit: { + describe: "reference to the commit initiating the vote", + type: "string", + }, + toCommit: { + type: "string", + describe: "reference to the last vote commit", + }, + prURL: { + describe: "URL to the PR to add to the vote summary file", + type: "string", + }, + comments: { + describe: "The body of the PR comments that contains the key part(s)", + type: "string", + }, + "commit-json-summary": { + describe: "Commit JSON summary", + type: "boolean", + }, + "save-markdown-summary": { + describe: "Write the markdown to a file (use - for stdout)", + type: "string", + }, + }, +}); + +const keyParts = JSON.parse(parsedArgs.comments) + .map( + (txt) => + /-----BEGIN SHAMIR KEY PART-----(.+)-----END SHAMIR KEY PART-----/s.exec( + txt + )?.[1] + ) + .filter(Boolean); + +const firstCommitRef = parsedArgs.fromCommit; +const voteFileCanonicalName = "vote.yml"; + +const subPath = await new Promise(async (resolve, reject) => { + const cp = spawn("git", [ + "--no-pager", + "show", + firstCommitRef, + "--name-only", + ]); + cp.on("error", reject); + for await (const line of readLines(cp.stdout)) { + if (line === voteFileCanonicalName) return resolve("./"); + if (line.endsWith(`/${voteFileCanonicalName}`)) + return resolve(line.slice(0, -voteFileCanonicalName.length)); + } +}); + +const { result, privateKey } = await countFromGit({ + cwd: fileURLToPath(new URL("../../", import.meta.url)), + repoURL: parsedArgs.remote, + branch: parsedArgs.branch, + subPath, + keyParts, + firstCommitRef, + lastCommitRef: parsedArgs.toCommit, + mailmap: parsedArgs.mailmap, + commitJsonSummary: parsedArgs["commit-json-summary"] + ? { + refs: parsedArgs.prURL, + } + : null, +}); + +if (parsedArgs["save-markdown-summary"]) { + function* toArmoredMessage(str, chunkSize = 64) { + yield "-----BEGIN PRIVATE KEY-----"; + for (let i = 0; i < str.length; i += chunkSize) { + yield str.substr(i, chunkSize); + } + yield "-----END PRIVATE KEY-----"; + } + await writeFile( + parsedArgs["save-markdown-summary"], + result.generateSummary( + Array.from( + toArmoredMessage(Buffer.from(privateKey).toString("base64")) + ).join("\n") + ), + "utf8" + ); +} diff --git a/votes/initiateNewVote/extractInfoFromReadme.mjs b/votes/initiateNewVote/extractInfoFromReadme.mjs new file mode 100644 index 00000000..10b9c04d --- /dev/null +++ b/votes/initiateNewVote/extractInfoFromReadme.mjs @@ -0,0 +1,19 @@ +export default async function* exportInfoFromReadme(iterator) { + const handleLine = /^\* \[([^\]]+)\]\(/; + const nameAndEmailLine = /^\s\s\*\*([^*]+)\*\* <<([^>]+)>>(?: \([^)]+\))?$/; + let isInsideTSCSection = false; + let currentMemberHandle; + for await (const line of iterator) { + if (currentMemberHandle != null) { + const [, name, email] = nameAndEmailLine.exec(line); + yield { handle: currentMemberHandle, name, email }; + currentMemberHandle = null; + } else if (isInsideTSCSection && line === "#### TSC regular members") { + break; + } else if (isInsideTSCSection && line.charAt(0) === "*") { + currentMemberHandle = handleLine.exec(line)[1]; + } else if (line === "#### TSC voting members") { + isInsideTSCSection = true; + } + } +} diff --git a/votes/initiateNewVote/generateNewVotePR.mjs b/votes/initiateNewVote/generateNewVotePR.mjs new file mode 100755 index 00000000..acb428c7 --- /dev/null +++ b/votes/initiateNewVote/generateNewVotePR.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node + +import { createInterface as readLines } from "node:readline"; +import { exit } from "node:process"; +import { Readable } from "node:stream"; +import { resolve } from "node:path"; +import { once } from "node:events"; +import { open, readFile } from "node:fs/promises"; +import { parseArgs } from "node:util"; +import { spawn } from "node:child_process"; + +import generateNewVoteFolder from "@node-core/caritat/generateNewVoteFolder"; + +import readReadme from "./extractInfoFromReadme.mjs"; + +const { values: argv } = parseArgs({ + options: { + remote: { + type: "string", + describe: + "Name or URL to the remote repo. If not provided, SSH pointing to --github-repo-name will be used.", + }, + ["github-repo-name"]: { + type: "string", + describe: "GitHub repository, in the format owner/repo", + default: "nodejs/TSC", + }, + "create-pull-request": { + type: "boolean", + describe: "Use GitHub API to create a Pull Request. Requires gh CLI tool", + }, + "pr-intro": { + type: "string", + describe: "Add an intro in markdown format for the PR body", + }, + "gpg-binary": { + type: "string", + }, + ["gpg-sign"]: { + type: "boolean", + short: "S", + describe: "GPG-sign commits.", + }, + "nodejs-repository-path": { + type: "string", + short: "r", + }, + "vote-repository-path": { + type: "string", + short: "R", + description: + "Path to the local vote repository. If not provided, it will be fetched from GitHub", + }, + subject: { + type: "string", + short: "s", + }, + branch: { + type: "string", + short: "b", + describe: "Name of the branch and subdirectory to use for the tests", + demandOption: true, + }, + candidate: { + type: "string", + multiple: true, + short: "c", + }, + "shuffle-candidates": { + type: "string", + }, + "header-instructions": { + type: "string", + }, + "footer-instructions": { + type: "string", + }, + vote: { + type: "boolean", + describe: "Register a vote just after the vote is initialized", + }, + abstain: { + type: "boolean", + describe: + "Use this flag to create a blank ballot and skip the voting if --vote is provided", + }, + "do-not-clean": { + type: "boolean", + describe: "Use this flag to keep temp files on the local FS", + }, + help: { + type: "boolean", + short: "h", + }, + version: { + type: "boolean", + short: "v", + }, + }, +}); + +if (argv.help) { + // TODO parse args subjects + console.log("Mandatory flags:"); + console.log( + "\t--branch (alias -b): A name for the vote to take place. It will also be used for naming the subfolder." + ); + console.log("\t--subject (alias -s): Subject of vote."); + console.log("Node.js specific options:"); + console.log( + "\t--nodejs-repository-path (alias -r): Path to a local clone of " + + "nodejs/node. If not provided, files will be downloaded from HTTPS." + ); + console.log( + "\t--vote-repository-path (alias -R): Path to a local clone of " + + "nodejs/TSC. If not provided, it will be cloned from SSH (or HTTPS if " + + "an HTTPS remote is provided)." + ); + console.log( + "\t--github-repo-name: GitHub repository, in the format owner/repo. Default is nodejs/TSC." + ); + console.log( + "\t--remote: Default is git@github.com:${{--github-repo-name}}.git." + ); + exit(0); +} + +if (argv.version) { + const pJson = await readFile( + new URL("../../package.json", import.meta.url), + "utf-8" + ); + console.log(JSON.parse(pJson).version); + exit(0); +} + +if (!argv.branch) { + throw new Error("You must pass a branch name"); +} +if (!argv.subject) { + throw new Error("You must pass a subject"); +} + +let input, crlfDelay; +if (argv["nodejs-repository-path"] == null) { + input = await fetch( + "https://raw.githubusercontent.com/nodejs/node/main/README.md" + ).then((res) => { + if (!res.ok) { + throw new Error("Wrong status code", { cause: res }); + } else { + return Readable.fromWeb(res.body); + } + }); +} else { + const fh = await open( + join(resolve(argv["nodejs-repository-path"]), "README.md"), + "r" + ); + input = fh.createReadStream(); + crlfDelay = Infinity; +} + +const tscMembersArray = []; +for await (const member of readReadme(readLines({ input, crlfDelay }))) { + tscMembersArray.push(member); +} + +input.destroy?.(); + +const shareholdersThreshold = 3; // Math.ceil(tscMembersArray.length / 4); + +const keyServerURL = "hkps://keys.openpgp.org"; + +await generateNewVoteFolder({ + candidates: argv.candidate, + headerInstructions: argv["header-instructions"], + footerInstructions: argv["footer-instructions"], + canShuffleCandidates: argv["shuffle-candidates"] !== "false", + subject: argv.subject, + gpgOptions: { + binaryPath: argv["gpg-binary"], + trustModel: "always", + keyServerURL, + }, + gitOptions: { + repo: argv.remote ?? `git@github.com:${argv["github-repo-name"]}.git`, + branch: argv.branch, + baseBranch: "main", + forceClone: !argv["vote-repository-path"], + }, + shareholdersThreshold, + shareholders: tscMembersArray.map(({ email }) => email), + allowedVoters: tscMembersArray.map( + (voter) => `${voter.name} <${voter.email}>` + ), + method: "Condorcet", + path: argv["vote-repository-path"] + ? resolve(argv["vote-repository-path"], argv.branch) + : argv.branch, +}); + +if (argv["create-pull-request"]) { + const cp = spawn( + "gh", + [ + "api", + `repos/${argv["github-repo-name"]}/pulls`, + "-F", + "base=main", + "-F", + `head=${argv.branch}`, + "-F", + `title=${argv.subject}`, + "-F", + `body=${argv["pr-intro"] ?? ""} + +The following users are invited to participate in this vote: + +${tscMembersArray + .map(({ name, handle }) => `- ${name} @${handle} (TSC)`) + .join("\n")} + +To close the vote, a minimum of ${shareholdersThreshold} key parts would need to be revealed. + +Vote instructions will follow.`, + "--jq", + ".html_url", + ], + { stdio: ["inherit", "pipe", "inherit"] } + ); + // @ts-ignore toArray does exist! + const out = cp.stdout.toArray(); + const [code] = await once(cp, "exit"); + if (code !== 0) exit(code); + + const prUrl = Buffer.concat(await out) + .toString() + .trim(); + + { + const cp = spawn( + "gh", + [ + "pr", + "edit", + prUrl, + "--body", + `${argv["pr-intro"] ?? ""} + +Vote instructions: + +- on the CLI: ${"`"}git node vote ${prUrl}${"`"} +- on the web: + +To close the vote, at least ${shareholdersThreshold} secret holder(s)[^1] must \ +run the following command: ${"`"}git node vote ${prUrl} --decrypt-key-part --post-comment${"`"} + +/cc @nodejs/tsc + +[^1]: secret holders are folks who have access to the private key associated with \ +a public key on <${keyServerURL}> that references an email address listed on the \ +TSC voting member list at the time of the opening of the vote. + `, + ], + { stdio: "inherit" } + ); + + const [code] = await once(cp, "exit"); + if (code !== 0) exit(code); + } + + console.log("PR created", prUrl); +} diff --git a/votes/initiateNewVote/getVoteStatus.mjs b/votes/initiateNewVote/getVoteStatus.mjs new file mode 100755 index 00000000..7af8fe15 --- /dev/null +++ b/votes/initiateNewVote/getVoteStatus.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { exit } from "node:process"; +import { env } from "node:process"; +import count from "@node-core/caritat/countParticipationFromGit"; + +const START_MARKER = ""; +const END_MARKER = ""; + +const participationResult = await count({ + subPath: env.SUBPATH, + firstCommitRef: env.FIRST_COMMIT_REF, + lastCommitRef: env.LAST_COMMIT_REF, + reportInvalidCommitsAfter: env.CHECK_COMMITS_AFTER, +}); + +const participation = participationResult?.participation; + +if (participation == null) { + console.error("Can't compute participation", participationResult); + exit(1); +} + +const mdParticipation = `\n\n${START_MARKER}\n\nCurrent estimated participation: ${ + Math.round(participation * 100_00) / 100 +}%\n\n${END_MARKER}\n`; + +let body = env.PR_DESCRIPTION || ""; +const startMarkerIndex = body.indexOf(START_MARKER); + +if (startMarkerIndex === -1) { + body += mdParticipation; +} else { + const endMarkerIndex = body.lastIndexOf(END_MARKER) + END_MARKER.length; + body = + body.slice(0, startMarkerIndex) + + mdParticipation + + (endMarkerIndex > startMarkerIndex && endMarkerIndex < body.length + ? body.slice(endMarkerIndex) + : ""); +} + +const invalidCommitReason = Object.entries( + participationResult.invalidCommits ?? {} +) + .map( + ([sha, reason]) => + `Commit ${sha} won't be taken into account for the following reason: ${reason}` + ) + .join("\n\n"); + +console.log( + JSON.stringify({ + body, + hasFailures: Boolean(invalidCommitReason), + invalidCommitReason, + }) +);