From dc65ee5f137b8c52452c70315b5588a6e3381572 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Wed, 8 Jul 2026 20:46:46 -0400 Subject: [PATCH 01/14] Spec/semconv integration workflows: finalize release PRs (release mode) - Adds a release mode to pick-branch: when the latest upstream release is newer than the version pinned on main, VERSION is the released tag and the existing integration branch is finalized instead of abandoned. - Reads the submodule pin from .gitmodules and parses it defensively (git-describe-style pins). - Emits MODE=dev|release to $GITHUB_ENV alongside VERSION/BRANCH. - Extracts the workflows' PR logic into a tested helper, scripts/gh/specs/ensure-pr, and replaces the PR-creation step of both workflows with a single step that calls it. - Dev mode: opens the draft integration PR when needed (bootstrapping the branch with an empty commit), as before. - Release mode: creates the non-draft release PR, promotes the existing draft (ready + title + body, written exactly once), or re-syncs the title only so maintainer notes in the body survive daily reruns. - The CLI validates MODE/VERSION/BRANCH from the environment and fails loudly on misconfiguration. --- .../update-semconv-integration-branch.yml | 17 +- .../update-spec-integration-branch.yml | 17 +- scripts/gh/specs/ensure-pr/cli.mjs | 98 +++++ scripts/gh/specs/ensure-pr/index.mjs | 281 ++++++++++++++ scripts/gh/specs/ensure-pr/inputs.test.mjs | 95 +++++ scripts/gh/specs/ensure-pr/pr.test.mjs | 363 ++++++++++++++++++ scripts/gh/specs/pick-branch/cli.mjs | 43 ++- scripts/gh/specs/pick-branch/format.test.mjs | 15 +- scripts/gh/specs/pick-branch/index.mjs | 67 +++- scripts/gh/specs/pick-branch/version.test.mjs | 163 ++++++++ 10 files changed, 1107 insertions(+), 52 deletions(-) create mode 100644 scripts/gh/specs/ensure-pr/cli.mjs create mode 100644 scripts/gh/specs/ensure-pr/index.mjs create mode 100644 scripts/gh/specs/ensure-pr/inputs.test.mjs create mode 100644 scripts/gh/specs/ensure-pr/pr.test.mjs diff --git a/.github/workflows/update-semconv-integration-branch.yml b/.github/workflows/update-semconv-integration-branch.yml index eec936eec52a..5e800819cf11 100644 --- a/.github/workflows/update-semconv-integration-branch.yml +++ b/.github/workflows/update-semconv-integration-branch.yml @@ -113,24 +113,11 @@ jobs: git push fi - - name: Create pull request if needed + - name: Create or finalize pull request env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: | - prs=$(gh pr list --state open --head $BRANCH) - if [ -z "$prs" ]; then - if [ -z "$(git rev-list origin/main..HEAD)" ]; then - # Bootstrap this long-lived integration branch with an empty - # commit so that PR creation succeeds (gh pr create fails when - # there are no commits between main and the branch). - git commit --allow-empty -m "Trigger PR creation for $BRANCH" - git push - fi - gh pr create --title "DRAFT Update ${{ env.REPO }} to unreleased $VERSION-dev" \ - --body "This is a draft PR used for identifying issues integrating the latest (unreleased) [${{ env.REPO }}](https://github.com/open-telemetry/${{ env.REPO }})." \ - --draft - fi + run: node scripts/gh/specs/ensure-pr/cli.mjs --spec=semconv report-failure: needs: update-semconv-integration-branch diff --git a/.github/workflows/update-spec-integration-branch.yml b/.github/workflows/update-spec-integration-branch.yml index 105ca64b6b88..8e11b75b7623 100644 --- a/.github/workflows/update-spec-integration-branch.yml +++ b/.github/workflows/update-spec-integration-branch.yml @@ -110,24 +110,11 @@ jobs: git push fi - - name: Create pull request if needed + - name: Create or finalize pull request env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: | - prs=$(gh pr list --state open --head $BRANCH) - if [ -z "$prs" ]; then - if [ -z "$(git rev-list origin/main..HEAD)" ]; then - # Bootstrap this long-lived integration branch with an empty - # commit so that PR creation succeeds (gh pr create fails when - # there are no commits between main and the branch). - git commit --allow-empty -m "Trigger PR creation for $BRANCH" - git push - fi - gh pr create --title "DRAFT Update ${{ env.REPO }} to unreleased $VERSION-dev" \ - --body "This is a draft PR used for identifying issues integrating the latest (unreleased) [${{ env.REPO }}](https://github.com/open-telemetry/${{ env.REPO }})." \ - --draft - fi + run: node scripts/gh/specs/ensure-pr/cli.mjs --spec=otel report-failure: needs: update-integration-branch diff --git a/scripts/gh/specs/ensure-pr/cli.mjs b/scripts/gh/specs/ensure-pr/cli.mjs new file mode 100644 index 000000000000..3c1be8faca40 --- /dev/null +++ b/scripts/gh/specs/ensure-pr/cli.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// CLI entry point: create or finalize the integration-branch PR of a spec +// workflow, according to the MODE picked by ../pick-branch. +// +// Usage: +// node scripts/gh/specs/ensure-pr/cli.mjs [--spec=] [--[no-]dry-run] +// +// Flags: +// -s, --spec= One of the keys defined in SPECS (e.g. `otel`, +// `semconv`). Defaults to `otel`. Determines the upstream +// repo name used in PR titles and bodies. +// --dry-run Skip side-effecting operations: no `git commit`/`git +// push`, no `gh pr create`/`ready`/`edit`. Read-only +// `git`/`gh` state queries still run. +// --no-dry-run Force writes even when running locally. +// Default: dry-run is ON unless GITHUB_ACTIONS=true. +// -h, --help Print usage and exit. +// +// Required environment: +// MODE, VERSION, BRANCH As written to $GITHUB_ENV by pick-branch; strictly +// validated (set them manually for local runs). +// GH_TOKEN Used by `gh`; needs PR write access when writes +// are enabled. +// +// The integration branch is expected to be checked out (with origin/main +// fetched) so that the bootstrap check `git rev-list origin/main..HEAD` is +// meaningful. + +import { spawnSync } from 'node:child_process'; + +import { parseCliArgs, SPECS } from '../pick-branch/index.mjs'; +import { cliUsage, ensurePullRequest, readEnvInputs } from './index.mjs'; + +main(); + +function main() { + let parsed; + try { + parsed = parseCliArgs(process.argv.slice(2)); + } catch (err) { + fatal(`${err.message}\n\n${cliUsage()}`); + } + + if (parsed.help) { + process.stdout.write(`${cliUsage()}\n`); + process.exit(0); + } + + const { spec, dryRun, dryRunReason } = parsed; + const { repo, abbr } = SPECS[spec]; + + let inputs; + try { + inputs = readEnvInputs(process.env, { abbr }); + } catch (err) { + fatal(`${err.message}\n\n${cliUsage()}`); + } + const { mode, version, branch } = inputs; + + console.log( + `[mode] ${dryRun ? 'DRY-RUN' : 'WRITE'} (reason: ${dryRunReason}; spec=${spec})`, + ); + console.log(`[input] MODE: ${mode}; VERSION: ${version}; BRANCH: ${branch}`); + + const { action } = ensurePullRequest({ + mode, + repo, + version, + branch, + dryRun, + runGh: (args) => run('gh', args), + runGit: (args) => run('git', args), + }); + + console.log(`[done] action: ${action}`); +} + +/** + * Run a command synchronously, returning `{ stdout, status }` so the pure + * helper can branch on exit code without throwing. Stderr passes through to + * the workflow log. + * + * @param {string} cmd + * @param {string[]} args + * @returns {{ stdout: string, status: number }} + */ +function run(cmd, args) { + const result = spawnSync(cmd, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + return { stdout: result.stdout ?? '', status: result.status ?? 1 }; +} + +function fatal(msg) { + process.stderr.write(`${msg}\n`); + process.exit(1); +} diff --git a/scripts/gh/specs/ensure-pr/index.mjs b/scripts/gh/specs/ensure-pr/index.mjs new file mode 100644 index 000000000000..25fd2f2924d0 --- /dev/null +++ b/scripts/gh/specs/ensure-pr/index.mjs @@ -0,0 +1,281 @@ +// Pure library for creating or finalizing the pull request of an +// "Update integration branch" workflow run, based on the MODE computed +// by ../pick-branch (dev vs release) and the current PR state. +// +// Side-effecting concerns (subprocess invocation, argv/env handling) live in +// ./cli.mjs. + +/** + * Result of an injected `gh`/`git` invocation. + * + * @typedef {Object} RunResult + * @property {string} stdout + * @property {number} status Exit code (0 on success). + */ + +/** + * @typedef {Object} EnsurePrResult + * @property {'none' + * | 'created-draft' | 'bootstrapped-and-created-draft' + * | 'created-release' | 'finalized' | 'title-synced' + * | 'would-create-draft' | 'would-bootstrap-and-create-draft' + * | 'would-create-release' | 'would-finalize' | 'would-sync-title' + * } action + * What was done (or, in dry-run mode, would have been done): + * - `none`: PR already in the desired state. + * - `created-draft` / `bootstrapped-and-created-draft`: dev mode opened the + * draft integration PR (bootstrapping with an empty commit when the + * branch had none). + * - `created-release`: release mode opened a new non-draft release PR. + * - `finalized`: release mode promoted the dev-cycle draft PR (ready + + * title + body). + * - `title-synced`: release mode re-synced the title of an already-final + * PR, leaving the body alone. + */ + +/** + * Ensure the integration branch has the pull request that the current MODE + * calls for: + * + * - **dev**: an open PR of any kind suffices; otherwise create the draft + * integration PR, bootstrapping the branch with an empty commit when it has + * no commits over main (`gh pr create` fails otherwise). + * - **release**: no PR → create the non-draft release PR; draft PR → one-time + * finalization (`gh pr ready` + title + body); non-draft PR → re-sync the + * title only (e.g. a newer release while the PR awaits merge), preserving + * any notes maintainers may have added to the body. + * + * The body of the release PR is thus written exactly once. + * + * In dry-run mode the read-only PR/branch state queries still run, but all + * writes are skipped and reported via `would-*` actions. + * + * @param {Object} input + * @param {'dev'|'release'} input.mode + * @param {string} input.repo Upstream repo name, e.g. `semantic-conventions`. + * @param {string} input.version Version to integrate, e.g. `v1.59.0`. + * @param {string} input.branch Integration branch name. + * @param {boolean} input.dryRun + * @param {(args: string[]) => RunResult} input.runGh + * Synchronous `gh` runner. Receives the argv (without `gh`). + * @param {(args: string[]) => RunResult} input.runGit + * Synchronous `git` runner. Receives the argv (without `git`). + * @param {(msg: string) => void} [input.log] + * @returns {EnsurePrResult} + */ +export function ensurePullRequest({ + mode, + repo, + version, + branch, + dryRun, + runGh, + runGit, + log = console.log, +}) { + if (mode !== 'dev' && mode !== 'release') { + throw new Error(`unexpected mode: ${mode} (expected dev or release)`); + } + + const list = must( + runGh([ + 'pr', + 'list', + '--state', + 'open', + '--head', + branch, + '--json', + 'number,isDraft,title', + ]), + 'gh pr list', + ); + const pr = JSON.parse(list.stdout || '[]')[0] ?? null; + + return mode === 'dev' + ? ensureDevPr({ pr, repo, version, branch, dryRun, runGh, runGit, log }) + : ensureReleasePr({ pr, repo, version, branch, dryRun, runGh, log }); +} + +/** Dev mode: make sure the draft integration PR exists. */ +function ensureDevPr({ + pr, + repo, + version, + branch, + dryRun, + runGh, + runGit, + log, +}) { + if (pr) { + log(`PR #${pr.number} is already open for ${branch}; nothing to do.`); + return { action: 'none' }; + } + + const revList = must( + runGit(['rev-list', 'origin/main..HEAD']), + 'git rev-list', + ); + const needsBootstrap = revList.stdout.trim() === ''; + + const title = `DRAFT Update ${repo} to unreleased ${version}-dev`; + const body = `This is a draft PR used for identifying issues integrating the latest (unreleased) [${repo}](https://github.com/open-telemetry/${repo}).`; + + if (dryRun) { + log( + `[dry-run] Would create draft PR "${title}"${needsBootstrap ? ' after bootstrapping the branch with an empty commit' : ''}.`, + ); + return { + action: needsBootstrap + ? 'would-bootstrap-and-create-draft' + : 'would-create-draft', + }; + } + + if (needsBootstrap) { + // Bootstrap this long-lived integration branch with an empty commit so + // that PR creation succeeds (gh pr create fails when there are no + // commits between main and the branch). + log(`Bootstrapping ${branch} with an empty commit.`); + must( + runGit([ + 'commit', + '--allow-empty', + '-m', + `Trigger PR creation for ${branch}`, + ]), + 'git commit', + ); + must(runGit(['push']), 'git push'); + } + + must( + runGh(['pr', 'create', '--title', title, '--body', body, '--draft']), + 'gh pr create', + ); + log(`Created draft PR "${title}".`); + return { + action: needsBootstrap ? 'bootstrapped-and-created-draft' : 'created-draft', + }; +} + +/** Release mode: create the release PR, or finalize/re-sync the existing one. */ +function ensureReleasePr({ pr, repo, version, branch, dryRun, runGh, log }) { + const title = `Update ${repo} version to ${version}`; + const body = `Update ${repo} version to \`${version}\`.\n\nSee https://github.com/open-telemetry/${repo}/releases/tag/${version}.`; + + if (!pr) { + if (dryRun) { + log(`[dry-run] Would create release PR "${title}".`); + return { action: 'would-create-release' }; + } + must( + runGh(['pr', 'create', '--title', title, '--body', body]), + 'gh pr create', + ); + log(`Created release PR "${title}".`); + return { action: 'created-release' }; + } + + if (pr.isDraft) { + // One-time finalization of the dev-cycle draft PR; the body is written + // here and never again. + if (dryRun) { + log(`[dry-run] Would finalize draft PR #${pr.number} as "${title}".`); + return { action: 'would-finalize' }; + } + must(runGh(['pr', 'ready', branch]), 'gh pr ready'); + must( + runGh(['pr', 'edit', branch, '--title', title, '--body', body]), + 'gh pr edit', + ); + log(`Finalized PR #${pr.number} as "${title}".`); + return { action: 'finalized' }; + } + + if (pr.title === title) { + log(`PR #${pr.number} is already finalized as "${title}"; nothing to do.`); + return { action: 'none' }; + } + + // Already finalized under another title (e.g. a newer release while this + // PR awaits merge): re-sync the title, but leave the body alone to + // preserve notes that maintainers may have added. + if (dryRun) { + log(`[dry-run] Would re-title PR #${pr.number} to "${title}".`); + return { action: 'would-sync-title' }; + } + must(runGh(['pr', 'edit', branch, '--title', title]), 'gh pr edit'); + log(`Re-titled PR #${pr.number} to "${title}".`); + return { action: 'title-synced' }; +} + +/** + * @param {RunResult} result + * @param {string} what Human-readable command label, e.g. `gh pr create`. + * @returns {RunResult} + */ +function must(result, what) { + if (result.status !== 0) { + throw new Error( + `${what} failed (status ${result.status}): ${result.stdout}`, + ); + } + return result; +} + +/** + * Validate and extract the MODE/VERSION/BRANCH environment values written to + * `$GITHUB_ENV` by ../pick-branch. Throws on missing or malformed values so + * that misconfiguration fails the workflow loudly. + * + * @param {Record} env + * @param {{ abbr: string }} spec The spec entry (for the branch pattern). + * @returns {{ mode: 'dev'|'release', version: string, branch: string }} + */ +export function readEnvInputs(env, { abbr }) { + const { MODE: mode, VERSION: version, BRANCH: branch } = env; + if (mode !== 'dev' && mode !== 'release') { + throw new Error(`unexpected MODE: ${mode} (expected dev or release)`); + } + if (!/^v\d+\.\d+\.\d+$/.test(version ?? '')) { + throw new Error(`unexpected VERSION: ${version} (expected vX.Y.Z)`); + } + const branchRe = new RegExp( + `^otelbot/${abbr}-integration-v\\d+\\.\\d+\\.\\d+-dev$`, + ); + if (!branchRe.test(branch ?? '')) { + throw new Error( + `unexpected BRANCH: ${branch} (expected otelbot/${abbr}-integration-vX.Y.Z-dev)`, + ); + } + return { mode, version, branch }; +} + +/** + * Help text for `cli.mjs --help`. + * + * @returns {string} + */ +export function cliUsage() { + return [ + 'Create or finalize the pull request of an integration-branch workflow', + 'run. In dev mode, open the draft integration PR if none exists; in', + 'release mode, create the release PR, promote the existing draft, or', + 're-sync the title of an already-final PR.', + '', + 'Reads MODE, VERSION and BRANCH from the environment (as written by', + 'pick-branch via $GITHUB_ENV) and expects the integration branch to be', + 'checked out.', + '', + 'Usage: node scripts/gh/specs/ensure-pr/cli.mjs \\', + ' [--spec=] [--[no-]dry-run]', + '', + 'Options:', + ' -s, --spec= Selects the upstream spec (default: otel).', + ' --dry-run Skip writes (default when run locally).', + ' --no-dry-run Perform writes (default under GitHub Actions).', + ' -h, --help Show this help.', + ].join('\n'); +} diff --git a/scripts/gh/specs/ensure-pr/inputs.test.mjs b/scripts/gh/specs/ensure-pr/inputs.test.mjs new file mode 100644 index 000000000000..9688645c1893 --- /dev/null +++ b/scripts/gh/specs/ensure-pr/inputs.test.mjs @@ -0,0 +1,95 @@ +// Tests for readEnvInputs: strict validation of the MODE/VERSION/BRANCH +// environment values that the ensure-pr CLI consumes. + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { readEnvInputs } from './index.mjs'; + +const SPEC = { repo: 'opentelemetry-specification', abbr: 'spec' }; + +const VALID_ENV = { + MODE: 'dev', + VERSION: 'v1.59.0', + BRANCH: 'otelbot/spec-integration-v1.59.0-dev', +}; + +describe('ensure-pr: readEnvInputs', () => { + test('accepts valid dev-mode inputs', () => { + const inputs = readEnvInputs(VALID_ENV, SPEC); + assert.deepEqual(inputs, { + mode: 'dev', + version: 'v1.59.0', + branch: 'otelbot/spec-integration-v1.59.0-dev', + }); + }); + + test('accepts valid release-mode inputs', () => { + const inputs = readEnvInputs({ ...VALID_ENV, MODE: 'release' }, SPEC); + assert.equal(inputs.mode, 'release'); + }); + + test('accepts a branch whose version differs from VERSION (patch release)', () => { + // Mid-cycle patch release: branch says v1.44.0-dev, VERSION is v1.43.1. + const inputs = readEnvInputs( + { + MODE: 'release', + VERSION: 'v1.43.1', + BRANCH: 'otelbot/semconv-integration-v1.44.0-dev', + }, + { repo: 'semantic-conventions', abbr: 'semconv' }, + ); + assert.equal(inputs.branch, 'otelbot/semconv-integration-v1.44.0-dev'); + }); + + test('rejects a missing MODE', () => { + assert.throws( + () => readEnvInputs({ ...VALID_ENV, MODE: undefined }, SPEC), + /MODE/, + ); + }); + + test('rejects an unknown MODE', () => { + assert.throws( + () => readEnvInputs({ ...VALID_ENV, MODE: 'prod' }, SPEC), + /MODE/, + ); + }); + + test('rejects a malformed VERSION', () => { + assert.throws( + () => readEnvInputs({ ...VALID_ENV, VERSION: '1.59.0' }, SPEC), + /VERSION/, + ); + assert.throws( + () => readEnvInputs({ ...VALID_ENV, VERSION: 'v1.59' }, SPEC), + /VERSION/, + ); + assert.throws( + () => readEnvInputs({ ...VALID_ENV, VERSION: undefined }, SPEC), + /VERSION/, + ); + }); + + test('rejects a BRANCH not matching the integration pattern', () => { + assert.throws( + () => readEnvInputs({ ...VALID_ENV, BRANCH: 'main' }, SPEC), + /BRANCH/, + ); + assert.throws( + () => readEnvInputs({ ...VALID_ENV, BRANCH: undefined }, SPEC), + /BRANCH/, + ); + }); + + test("rejects a BRANCH for a different spec's abbreviation", () => { + assert.throws( + () => + readEnvInputs( + { ...VALID_ENV, BRANCH: 'otelbot/semconv-integration-v1.59.0-dev' }, + SPEC, + ), + /BRANCH/, + ); + }); +}); diff --git a/scripts/gh/specs/ensure-pr/pr.test.mjs b/scripts/gh/specs/ensure-pr/pr.test.mjs new file mode 100644 index 000000000000..485bb16bea41 --- /dev/null +++ b/scripts/gh/specs/ensure-pr/pr.test.mjs @@ -0,0 +1,363 @@ +// State-table tests for ensurePullRequest: the mode-aware PR create/finalize +// helper driven by injected `gh` and `git` runners. + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ensurePullRequest } from './index.mjs'; + +const noLog = () => {}; + +/** + * Build a fake runner that records calls and serves canned responses keyed by + * the first two argv tokens (e.g. `pr list`, `pr create`). Default response is + * `{ stdout: '', status: 0 }`. + */ +function makeFakeRunner(responses = {}) { + const calls = []; + const run = (args) => { + calls.push(args); + const key = args.slice(0, 2).join(' '); + return responses[key] ?? { stdout: '', status: 0 }; + }; + return { run, calls }; +} + +/** Argv of the first recorded call whose leading tokens match `prefix`. */ +function findCall(calls, ...prefix) { + return calls.find((args) => prefix.every((token, i) => args[i] === token)); +} + +/** Value following `flag` in `args`. */ +function flagValue(args, flag) { + return args[args.indexOf(flag) + 1]; +} + +const INPUT = { + repo: 'opentelemetry-specification', + version: 'v1.59.0', + branch: 'otelbot/spec-integration-v1.59.0-dev', +}; + +const NO_PR = { 'pr list': { stdout: '[]', status: 0 } }; +const OPEN_DRAFT_PR = { + 'pr list': { stdout: '[{"number":10526,"isDraft":true}]', status: 0 }, +}; +const OPEN_READY_PR = { + 'pr list': { stdout: '[{"number":10526,"isDraft":false}]', status: 0 }, +}; + +describe('ensure-pr: dev mode', () => { + test('PR already open: no-op', () => { + const gh = makeFakeRunner(OPEN_DRAFT_PR); + const git = makeFakeRunner(); + const result = ensurePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'none'); + assert.equal(gh.calls.length, 1, 'only the pr-list call runs'); + assert.equal(git.calls.length, 0, 'no git calls'); + }); + + test('no PR, branch has commits: creates draft PR', () => { + const gh = makeFakeRunner(NO_PR); + const git = makeFakeRunner({ + 'rev-list origin/main..HEAD': { stdout: 'abc123\n', status: 0 }, + }); + const result = ensurePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'created-draft'); + const create = findCall(gh.calls, 'pr', 'create'); + assert.ok(create, 'gh pr create is called'); + assert.ok(create.includes('--draft'), 'PR is created as draft'); + assert.equal( + flagValue(create, '--title'), + 'DRAFT Update opentelemetry-specification to unreleased v1.59.0-dev', + ); + assert.match( + flagValue(create, '--body'), + /draft PR used for identifying issues integrating the latest \(unreleased\) \[opentelemetry-specification\]\(https:\/\/github\.com\/open-telemetry\/opentelemetry-specification\)/, + ); + assert.ok( + !findCall(git.calls, 'commit'), + 'no bootstrap commit when branch has commits', + ); + }); + + test('no PR, branch even with main: bootstraps with an empty commit', () => { + const gh = makeFakeRunner(NO_PR); + const git = makeFakeRunner({ + 'rev-list origin/main..HEAD': { stdout: '', status: 0 }, + }); + const result = ensurePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'bootstrapped-and-created-draft'); + const commit = findCall(git.calls, 'commit'); + assert.ok(commit, 'git commit is called'); + assert.ok(commit.includes('--allow-empty'), 'commit is empty'); + assert.equal( + flagValue(commit, '-m'), + `Trigger PR creation for ${INPUT.branch}`, + ); + assert.ok(findCall(git.calls, 'push'), 'bootstrap commit is pushed'); + assert.ok(findCall(gh.calls, 'pr', 'create'), 'draft PR is created'); + // Bootstrap must happen before PR creation. + assert.ok( + git.calls.length > 1, + 'git bootstrap calls precede the gh pr create call', + ); + }); +}); + +describe('ensure-pr: release mode', () => { + const RELEASE_TITLE = 'Update opentelemetry-specification version to v1.59.0'; + + test('no PR: creates non-draft release PR', () => { + const gh = makeFakeRunner(NO_PR); + const git = makeFakeRunner({ + 'rev-list origin/main..HEAD': { stdout: 'abc123\n', status: 0 }, + }); + const result = ensurePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'created-release'); + const create = findCall(gh.calls, 'pr', 'create'); + assert.ok(create, 'gh pr create is called'); + assert.ok(!create.includes('--draft'), 'release PR is not a draft'); + assert.equal(flagValue(create, '--title'), RELEASE_TITLE); + const body = flagValue(create, '--body'); + assert.match(body, /`v1\.59\.0`/); + assert.match( + body, + /https:\/\/github\.com\/open-telemetry\/opentelemetry-specification\/releases\/tag\/v1\.59\.0/, + ); + }); + + test('open draft PR: one-time finalization (ready + title + body)', () => { + const gh = makeFakeRunner(OPEN_DRAFT_PR); + const git = makeFakeRunner(); + const result = ensurePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'finalized'); + const ready = findCall(gh.calls, 'pr', 'ready'); + assert.ok(ready, 'gh pr ready is called'); + assert.ok(ready.includes(INPUT.branch), 'pr ready targets the branch'); + const edit = findCall(gh.calls, 'pr', 'edit'); + assert.ok(edit, 'gh pr edit is called'); + assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); + assert.ok(edit.includes('--body'), 'finalization writes the body once'); + assert.equal(git.calls.length, 0, 'no git calls'); + }); + + test('open ready PR: re-syncs the title only', () => { + const gh = makeFakeRunner(OPEN_READY_PR); + const git = makeFakeRunner(); + const result = ensurePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'title-synced'); + assert.ok(!findCall(gh.calls, 'pr', 'ready'), 'pr ready is not re-run'); + const edit = findCall(gh.calls, 'pr', 'edit'); + assert.ok(edit, 'gh pr edit is called'); + assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); + assert.ok( + !edit.includes('--body'), + 'body is left alone to preserve maintainer notes', + ); + }); + + test('open ready PR with matching title: full no-op', () => { + const gh = makeFakeRunner({ + 'pr list': { + stdout: `[{"number":10526,"isDraft":false,"title":${JSON.stringify(RELEASE_TITLE)}}]`, + status: 0, + }, + }); + const git = makeFakeRunner(); + const result = ensurePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'none'); + assert.equal(gh.calls.length, 1, 'only the pr-list call runs'); + }); +}); + +describe('ensure-pr: dry-run', () => { + test('dev mode: reads state but skips all writes', () => { + const gh = makeFakeRunner(NO_PR); + const git = makeFakeRunner({ + 'rev-list origin/main..HEAD': { stdout: '', status: 0 }, + }); + const logs = []; + const result = ensurePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: true, + runGh: gh.run, + runGit: git.run, + log: (m) => logs.push(m), + }); + assert.equal(result.action, 'would-bootstrap-and-create-draft'); + assert.equal(gh.calls.length, 1, 'only the read-only pr-list call runs'); + assert.ok( + !findCall(git.calls, 'commit') && !findCall(git.calls, 'push'), + 'no git writes in dry-run', + ); + assert.ok( + logs.some((m) => /\[dry-run\]/.test(m)), + 'dry-run intent is logged', + ); + }); + + test('release mode with draft PR: skips finalize writes', () => { + const gh = makeFakeRunner(OPEN_DRAFT_PR); + const git = makeFakeRunner(); + const result = ensurePullRequest({ + ...INPUT, + mode: 'release', + dryRun: true, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result.action, 'would-finalize'); + assert.equal(gh.calls.length, 1, 'only the read-only pr-list call runs'); + }); +}); + +describe('ensure-pr: failure propagation', () => { + test('throws when gh pr list fails', () => { + const gh = makeFakeRunner({ 'pr list': { stdout: 'boom', status: 4 } }); + const git = makeFakeRunner(); + assert.throws( + () => + ensurePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /gh pr list failed/, + ); + }); + + test('throws when gh pr create fails', () => { + const gh = makeFakeRunner({ + ...NO_PR, + 'pr create': { stdout: 'nope', status: 1 }, + }); + const git = makeFakeRunner({ + 'rev-list origin/main..HEAD': { stdout: 'abc123\n', status: 0 }, + }); + assert.throws( + () => + ensurePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /gh pr create failed/, + ); + }); + + test('throws when gh pr ready fails during finalization', () => { + const gh = makeFakeRunner({ + ...OPEN_DRAFT_PR, + 'pr ready': { stdout: '', status: 1 }, + }); + const git = makeFakeRunner(); + assert.throws( + () => + ensurePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /gh pr ready failed/, + ); + }); + + test('throws when git bootstrap commit fails', () => { + const gh = makeFakeRunner(NO_PR); + const git = makeFakeRunner({ + 'rev-list origin/main..HEAD': { stdout: '', status: 0 }, + 'commit --allow-empty': { stdout: '', status: 1 }, + }); + assert.throws( + () => + ensurePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /git commit failed/, + ); + }); + + test('throws on invalid mode', () => { + const gh = makeFakeRunner(); + const git = makeFakeRunner(); + assert.throws( + () => + ensurePullRequest({ + ...INPUT, + mode: 'prod', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /unexpected mode: prod/, + ); + assert.equal(gh.calls.length, 0, 'no calls are made on invalid input'); + }); +}); diff --git a/scripts/gh/specs/pick-branch/cli.mjs b/scripts/gh/specs/pick-branch/cli.mjs index 5fad0d2e59a9..4bcf26849234 100644 --- a/scripts/gh/specs/pick-branch/cli.mjs +++ b/scripts/gh/specs/pick-branch/cli.mjs @@ -15,10 +15,12 @@ // Default: dry-run is ON unless GITHUB_ACTIONS=true. // -h, --help Print usage and exit. // +// Outputs MODE (dev|release), VERSION and BRANCH; see cliUsage() in index.mjs. +// // Required environment when writes are enabled: // GH_TOKEN Used by `gh`; needs `issues: write` for issue creation. // GITHUB_ENV Path written by GitHub Actions. Optional locally; if -// unset, VERSION/BRANCH are written to stdout only. +// unset, MODE/VERSION/BRANCH are written to stdout only. import { execFileSync, spawnSync } from 'node:child_process'; import { appendFileSync } from 'node:fs'; @@ -77,6 +79,18 @@ function main() { encoding: 'utf8', }); + // Submodule pin on the current checkout (main), e.g. `spec-pin = v1.58.0`. + const pinnedVersion = execFileSync( + 'git', + [ + 'config', + '-f', + '.gitmodules', + `submodule.content-modules/${repo}.${abbr}-pin`, + ], + { encoding: 'utf8' }, + ).trim(); + const isReleased = (version) => spawnSync('git', ['ls-remote', '--exit-code', '--tags', repoUrl, version], { stdio: 'ignore', @@ -100,18 +114,27 @@ function main() { return out.trim(); }; - const { version, branch, warnings, latestRelease } = - computeIntegrationVersion({ - branchPrefix, - branchesOutput, - isReleased, - getLatestReleaseTag, - }); + const { + mode: pickMode, + version, + branch, + warnings, + latestRelease, + } = computeIntegrationVersion({ + branchPrefix, + branchesOutput, + pinnedVersion, + isReleased, + getLatestReleaseTag, + }); console.log(`[upstream] Latest released ${repo} version: ${latestRelease}`); - console.log(`[picked] Next dev VERSION: ${version} (BRANCH: ${branch})`); + console.log(`[pinned] Version pinned on main: ${pinnedVersion}`); + console.log( + `[picked] MODE: ${pickMode}; VERSION: ${version} (BRANCH: ${branch})`, + ); - const envLines = formatGithubEnv({ version, branch }); + const envLines = formatGithubEnv({ mode: pickMode, version, branch }); if (githubEnv) appendFileSync(githubEnv, envLines); process.stdout.write(envLines); diff --git a/scripts/gh/specs/pick-branch/format.test.mjs b/scripts/gh/specs/pick-branch/format.test.mjs index cfaa1afdc48d..467082871d89 100644 --- a/scripts/gh/specs/pick-branch/format.test.mjs +++ b/scripts/gh/specs/pick-branch/format.test.mjs @@ -18,14 +18,15 @@ describe('pick-branch: format helpers', () => { }); }); - test('formatGithubEnv: emits VERSION and BRANCH lines with trailing newline', () => { + test('formatGithubEnv: emits MODE, VERSION and BRANCH lines with trailing newline', () => { const out = formatGithubEnv({ + mode: 'dev', version: 'v1.42.0', branch: 'otelbot/spec-integration-v1.42.0-dev', }); assert.equal( out, - 'VERSION=v1.42.0\nBRANCH=otelbot/spec-integration-v1.42.0-dev\n', + 'MODE=dev\nVERSION=v1.42.0\nBRANCH=otelbot/spec-integration-v1.42.0-dev\n', ); // Ensure parsing back through `key=value` round-trips cleanly. const parsed = Object.fromEntries( @@ -35,11 +36,21 @@ describe('pick-branch: format helpers', () => { .map((line) => line.split('=')), ); assert.deepEqual(parsed, { + MODE: 'dev', VERSION: 'v1.42.0', BRANCH: 'otelbot/spec-integration-v1.42.0-dev', }); }); + test('formatGithubEnv: release mode', () => { + const out = formatGithubEnv({ + mode: 'release', + version: 'v1.59.0', + branch: 'otelbot/spec-integration-v1.59.0-dev', + }); + assert.match(out, /^MODE=release\n/); + }); + test('buildIssueBody: includes warnings, repo link, and run url when provided', () => { const body = buildIssueBody({ warnings: ['stale branches found', 'something else'], diff --git a/scripts/gh/specs/pick-branch/index.mjs b/scripts/gh/specs/pick-branch/index.mjs index 22fdb494a232..d653d4b9fb58 100644 --- a/scripts/gh/specs/pick-branch/index.mjs +++ b/scripts/gh/specs/pick-branch/index.mjs @@ -23,6 +23,9 @@ export const SPECS = Object.freeze({ * Branch prefix, e.g. `otelbot/spec-integration`. * @property {string} branchesOutput * Raw output of `git branch -r` (one ref per line). + * @property {string} pinnedVersion + * Raw submodule pin from `.gitmodules` on main (e.g. `v1.58.0`, or + * `git describe` output such as `v0.15.0-22-g3bcd6e7d`). * @property {(version: string) => boolean} isReleased * Returns true iff `version` is an existing tag in the upstream repo. * @property {() => string} getLatestReleaseTag @@ -36,14 +39,18 @@ export const SPECS = Object.freeze({ /** * @param {ComputeInput} input - * @returns {{ version: string, branch: string, warnings: string[], latestRelease: string }} - * `version` is the next dev version to integrate (e.g. `v1.56.0`). + * @returns {{ mode: 'dev'|'release', version: string, branch: string, warnings: string[], latestRelease: string }} + * `mode` is `release` when the latest upstream release is newer than the + * version pinned on main — the integration branch should then finalize into + * the release PR; otherwise `dev`. + * `version` is the version to integrate (e.g. `v1.56.0`). * `latestRelease` is the most recent published release tag of the upstream * repo (e.g. `v1.55.0`), included for reporting. */ export function computeIntegrationVersion({ branchPrefix, branchesOutput, + pinnedVersion, isReleased, getLatestReleaseTag, log = console.log, @@ -56,6 +63,7 @@ export function computeIntegrationVersion({ }; const latestRelease = getLatestReleaseTag(); + const pinned = parsePinnedVersion(pinnedVersion); let version = extractVersionFromBranches( branchesOutput, @@ -63,6 +71,22 @@ export function computeIntegrationVersion({ collectWarning, ); + if (isNewer(latestRelease, pinned)) { + log( + `Latest release ${latestRelease} is newer than the pinned version ${pinned}; release mode.`, + ); + return { + mode: 'release', + version: latestRelease, + // Keep the existing integration branch if there is one, even when its + // embedded version differs (e.g. a patch release mid-cycle) — the + // mismatch is cosmetic and the branch carries accumulated work. + branch: `${branchPrefix}-${version || latestRelease}-dev`, + warnings, + latestRelease, + }; + } + if (version && isReleased(version)) { const bumped = bumpMinor(version); log(`Version ${version} has already been released; bumping to ${bumped}.`); @@ -76,6 +100,7 @@ export function computeIntegrationVersion({ } return { + mode: 'dev', version, branch: `${branchPrefix}-${version}-dev`, warnings, @@ -126,6 +151,26 @@ function compareVersionsDesc(a, b) { return bMajor - aMajor || bMinor - aMinor || bPatch - aPatch; } +/** @param {string} a @param {string} b @returns {boolean} True iff `a` > `b`. */ +function isNewer(a, b) { + return compareVersionsDesc(a, b) < 0; +} + +/** + * Extract the leading `vMAJOR.MINOR.PATCH` from a `.gitmodules` submodule pin. + * Pins are `git describe` output, so they may carry a `-N-gHASH` suffix + * (e.g. `v0.15.0-22-g3bcd6e7d`). Throws if no leading version tag is found + * (e.g. bare-hash pins). + * + * @param {string} pin + * @returns {string} + */ +export function parsePinnedVersion(pin) { + const m = (pin ?? '').match(/^(v\d+\.\d+\.\d+)(-|$)/); + if (!m) throw new Error(`unexpected pin: ${pin}`); + return m[1]; +} + /** * Bump the minor component of a `vMAJOR.MINOR.PATCH[...]` tag, resetting * patch to 0. Throws if the tag does not match. @@ -145,14 +190,14 @@ function escapeRegExp(s) { } /** - * Format the lines that get appended to `$GITHUB_ENV` to expose VERSION and - * BRANCH to subsequent workflow steps. + * Format the lines that get appended to `$GITHUB_ENV` to expose MODE, VERSION + * and BRANCH to subsequent workflow steps. * - * @param {{ version: string, branch: string }} input + * @param {{ mode: 'dev'|'release', version: string, branch: string }} input * @returns {string} */ -export function formatGithubEnv({ version, branch }) { - return `VERSION=${version}\nBRANCH=${branch}\n`; +export function formatGithubEnv({ mode, version, branch }) { + return `MODE=${mode}\nVERSION=${version}\nBRANCH=${branch}\n`; } /** @@ -356,9 +401,11 @@ export function parseCliArgs(argv, env = process.env) { export function cliUsage() { const allowed = Object.keys(SPECS).join('|'); return [ - 'Pick the next integration-branch version for a spec workflow and write', - 'VERSION/BRANCH to $GITHUB_ENV (or stdout when GITHUB_ENV is unset).', - 'Opens a tracking issue on warnings.', + 'Pick the integration-branch version and mode for a spec workflow and', + 'write MODE/VERSION/BRANCH to $GITHUB_ENV (or stdout when GITHUB_ENV is', + 'unset). MODE is `release` when the latest upstream release is newer than', + 'the version pinned on main, else `dev`. Opens a tracking issue on', + 'warnings.', '', 'Usage: node scripts/gh/specs/pick-branch/cli.mjs \\', ` [--spec=<${allowed}>] [--[no-]dry-run]`, diff --git a/scripts/gh/specs/pick-branch/version.test.mjs b/scripts/gh/specs/pick-branch/version.test.mjs index b5daad44c85e..7b295ec4a884 100644 --- a/scripts/gh/specs/pick-branch/version.test.mjs +++ b/scripts/gh/specs/pick-branch/version.test.mjs @@ -8,6 +8,7 @@ import { bumpMinor, computeIntegrationVersion, extractVersionFromBranches, + parsePinnedVersion, } from './index.mjs'; const PREFIX = 'otelbot/spec-integration'; @@ -66,6 +67,7 @@ describe('pick-branch: version pipeline', () => { const result = computeIntegrationVersion({ branchPrefix: PREFIX, branchesOutput: ' origin/main\n', + pinnedVersion: 'v1.42.0', isReleased: () => { throw new Error('isReleased should not be called'); }, @@ -73,6 +75,7 @@ describe('pick-branch: version pipeline', () => { log: noLog, }); assert.deepEqual(result, { + mode: 'dev', version: 'v1.43.0', branch: `${PREFIX}-v1.43.0-dev`, warnings: [], @@ -85,6 +88,7 @@ describe('pick-branch: version pipeline', () => { const result = computeIntegrationVersion({ branchPrefix: PREFIX, branchesOutput: ` origin/${PREFIX}-v1.42.0-dev\n`, + pinnedVersion: 'v1.41.0', isReleased: (v) => { assert.equal(v, 'v1.42.0'); return false; @@ -97,6 +101,7 @@ describe('pick-branch: version pipeline', () => { }); assert.equal(getLatestCalled, true); assert.deepEqual(result, { + mode: 'dev', version: 'v1.42.0', branch: `${PREFIX}-v1.42.0-dev`, warnings: [], @@ -109,6 +114,7 @@ describe('pick-branch: version pipeline', () => { const result = computeIntegrationVersion({ branchPrefix: PREFIX, branchesOutput: ` origin/${PREFIX}-v1.42.0-dev\n`, + pinnedVersion: 'v1.42.0', isReleased: (v) => v === 'v1.42.0', getLatestReleaseTag: () => { getLatestCalled = true; @@ -118,6 +124,7 @@ describe('pick-branch: version pipeline', () => { }); assert.equal(getLatestCalled, true); assert.deepEqual(result, { + mode: 'dev', version: 'v1.43.0', branch: `${PREFIX}-v1.43.0-dev`, warnings: [], @@ -132,10 +139,12 @@ describe('pick-branch: version pipeline', () => { origin/${PREFIX}-v1.41.0-dev origin/${PREFIX}-v1.42.0-dev `, + pinnedVersion: 'v1.40.0', isReleased: () => false, getLatestReleaseTag: () => 'v1.40.0', log: noLog, }); + assert.equal(result.mode, 'dev'); assert.equal(result.version, 'v1.42.0'); assert.equal(result.branch, `${PREFIX}-v1.42.0-dev`); assert.equal(result.warnings.length, 1); @@ -153,6 +162,7 @@ describe('pick-branch: version pipeline', () => { origin/${PREFIX}-v1.41.0-dev origin/${PREFIX}-v1.42.0-dev `, + pinnedVersion: 'v1.40.0', isReleased: () => false, getLatestReleaseTag: () => 'v1.40.0', log: (m) => logs.push(m), @@ -173,6 +183,7 @@ describe('pick-branch: version pipeline', () => { computeIntegrationVersion({ branchPrefix: PREFIX, branchesOutput: ' origin/main\n', + pinnedVersion: 'v1.42.0', isReleased: () => false, getLatestReleaseTag: () => 'not-a-version', log: noLog, @@ -188,6 +199,7 @@ describe('pick-branch: version pipeline', () => { computeIntegrationVersion({ branchPrefix: PREFIX, branchesOutput: ` origin/${PREFIX}-v1.42.0-dev\n`, + pinnedVersion: 'v1.42.0', isReleased: () => false, getLatestReleaseTag: () => 'v1.42.0', log, @@ -200,6 +212,7 @@ describe('pick-branch: version pipeline', () => { computeIntegrationVersion({ branchPrefix: PREFIX, branchesOutput: ` origin/${PREFIX}-v1.42.0-dev\n`, + pinnedVersion: 'v1.42.0', isReleased: () => true, getLatestReleaseTag: () => 'v1.42.0', log, @@ -209,3 +222,153 @@ describe('pick-branch: version pipeline', () => { ]); }); }); + +describe('pick-branch: pinned-version parsing', () => { + test('parsePinnedVersion: plain tag pin', () => { + assert.equal(parsePinnedVersion('v1.58.0'), 'v1.58.0'); + }); + + test('parsePinnedVersion: git-describe pin -> leading tag', () => { + assert.equal(parsePinnedVersion('v0.15.0-22-g3bcd6e7d'), 'v0.15.0'); + }); + + test('parsePinnedVersion: throws on malformed pin', () => { + assert.throws(() => parsePinnedVersion('e9411ee'), /unexpected pin/); + assert.throws(() => parsePinnedVersion(''), /unexpected pin/); + assert.throws(() => parsePinnedVersion(undefined), /unexpected pin/); + }); +}); + +describe('pick-branch: mode selection', () => { + const isReleasedUnexpected = () => { + throw new Error('isReleased should not be called in release mode'); + }; + + test('release mode: pinned behind latest -> finalize existing branch', () => { + const result = computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ` origin/${PREFIX}-v1.59.0-dev\n`, + pinnedVersion: 'v1.58.0', + isReleased: isReleasedUnexpected, + getLatestReleaseTag: () => 'v1.59.0', + log: noLog, + }); + assert.deepEqual(result, { + mode: 'release', + version: 'v1.59.0', + branch: `${PREFIX}-v1.59.0-dev`, + warnings: [], + latestRelease: 'v1.59.0', + }); + }); + + test('release mode: patch release keeps existing branch name', () => { + const result = computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ` origin/${PREFIX}-v1.44.0-dev\n`, + pinnedVersion: 'v1.43.0', + isReleased: isReleasedUnexpected, + getLatestReleaseTag: () => 'v1.43.1', + log: noLog, + }); + assert.equal(result.mode, 'release'); + assert.equal(result.version, 'v1.43.1'); + // Branch name mismatch (v1.44.0-dev vs v1.43.1) is cosmetic; keep it. + assert.equal(result.branch, `${PREFIX}-v1.44.0-dev`); + }); + + test('release mode: major bump', () => { + const result = computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ` origin/${PREFIX}-v1.59.0-dev\n`, + pinnedVersion: 'v1.58.0', + isReleased: isReleasedUnexpected, + getLatestReleaseTag: () => 'v2.0.0', + log: noLog, + }); + assert.equal(result.mode, 'release'); + assert.equal(result.version, 'v2.0.0'); + assert.equal(result.branch, `${PREFIX}-v1.59.0-dev`); + }); + + test('release mode: no branch -> create branch for latest release', () => { + const result = computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ' origin/main\n', + pinnedVersion: 'v1.58.0', + isReleased: isReleasedUnexpected, + getLatestReleaseTag: () => 'v1.59.0', + log: noLog, + }); + assert.deepEqual(result, { + mode: 'release', + version: 'v1.59.0', + branch: `${PREFIX}-v1.59.0-dev`, + warnings: [], + latestRelease: 'v1.59.0', + }); + }); + + test('release mode: multiple branches still warn, latest wins', () => { + const result = computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ` + origin/${PREFIX}-v1.58.0-dev + origin/${PREFIX}-v1.59.0-dev +`, + pinnedVersion: 'v1.58.0', + isReleased: isReleasedUnexpected, + getLatestReleaseTag: () => 'v1.59.0', + log: noLog, + }); + assert.equal(result.mode, 'release'); + assert.equal(result.version, 'v1.59.0'); + assert.equal(result.branch, `${PREFIX}-v1.59.0-dev`); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /Multiple integration branches found/); + }); + + test('release mode: log explains the mode decision', () => { + const logs = []; + computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ` origin/${PREFIX}-v1.59.0-dev\n`, + pinnedVersion: 'v1.58.0', + isReleased: isReleasedUnexpected, + getLatestReleaseTag: () => 'v1.59.0', + log: (m) => logs.push(m), + }); + assert.equal(logs.length, 1); + assert.match(logs[0], /release mode/i); + assert.match(logs[0], /v1\.59\.0/); + assert.match(logs[0], /v1\.58\.0/); + }); + + test('dev mode: git-describe pin not behind latest', () => { + const result = computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ' origin/main\n', + pinnedVersion: 'v0.15.0-22-g3bcd6e7d', + isReleased: () => false, + getLatestReleaseTag: () => 'v0.15.0', + log: noLog, + }); + assert.equal(result.mode, 'dev'); + assert.equal(result.version, 'v0.16.0'); + }); + + test('compute: bubbles up malformed pin', () => { + assert.throws( + () => + computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ' origin/main\n', + pinnedVersion: 'e9411ee', + isReleased: () => false, + getLatestReleaseTag: () => 'v1.59.0', + log: noLog, + }), + /unexpected pin/, + ); + }); +}); From 1a6409004e39a45a2c2488310bf66428c939002a Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Wed, 8 Jul 2026 20:47:10 -0400 Subject: [PATCH 02/14] Remove spec and semconv from auto-update-versions - Drops the opentelemetry-specification and semantic-conventions entries from all-versions.sh: releases of these repos are now handled by the integration workflows, which finalize the accumulated integration branch into the release PR instead of opening a fresh one. - Leaves version-in-file.sh unchanged (still used by the remaining repos and for manual runs). --- scripts/auto-update/all-versions.sh | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scripts/auto-update/all-versions.sh b/scripts/auto-update/all-versions.sh index e90cf822133f..9d4c7cf52e1a 100755 --- a/scripts/auto-update/all-versions.sh +++ b/scripts/auto-update/all-versions.sh @@ -21,15 +21,9 @@ function auto_update_versions() { contrib content/en/docs/languages/java/_index.md" "opentelemetry-android ot-android content/en/docs/platforms/client-apps/android.md" - "opentelemetry-specification - spec scripts/content-modules/adjust-pages.pl - spec .gitmodules" "opentelemetry-proto otlp scripts/content-modules/adjust-pages.pl otlp .gitmodules" - "semantic-conventions - semconv scripts/content-modules/adjust-pages.pl - semconv .gitmodules" "semantic-conventions-java semconv content/en/docs/languages/java/_index.md" "opentelemetry-configuration From 5304c1a61ba5fa98c38cf981f1d57d367b2d5b82 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 07:27:12 -0400 Subject: [PATCH 03/14] Rename ensure-pr helper to create-or-finalize-pr - The "ensure" idiom obscured what the helper actually does (create the draft or release PR, finalize the draft, re-sync the title); the new name is transparent and matches the workflow step name "Create or finalize pull request". - Renames the script directory and identifiers (`ensurePullRequest()` -> `createOrFinalizePullRequest()`, etc.); no behavior change. --- .../update-semconv-integration-branch.yml | 2 +- .../update-spec-integration-branch.yml | 2 +- .../cli.mjs | 10 +++-- .../index.mjs | 45 ++++++++++++++----- .../inputs.test.mjs | 4 +- .../pr.test.mjs | 40 ++++++++--------- 6 files changed, 66 insertions(+), 37 deletions(-) rename scripts/gh/specs/{ensure-pr => create-or-finalize-pr}/cli.mjs (92%) rename scripts/gh/specs/{ensure-pr => create-or-finalize-pr}/index.mjs (92%) rename scripts/gh/specs/{ensure-pr => create-or-finalize-pr}/inputs.test.mjs (95%) rename scripts/gh/specs/{ensure-pr => create-or-finalize-pr}/pr.test.mjs (91%) diff --git a/.github/workflows/update-semconv-integration-branch.yml b/.github/workflows/update-semconv-integration-branch.yml index 5e800819cf11..2179e34c33df 100644 --- a/.github/workflows/update-semconv-integration-branch.yml +++ b/.github/workflows/update-semconv-integration-branch.yml @@ -117,7 +117,7 @@ jobs: env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: node scripts/gh/specs/ensure-pr/cli.mjs --spec=semconv + run: node scripts/gh/specs/create-or-finalize-pr/cli.mjs --spec=semconv report-failure: needs: update-semconv-integration-branch diff --git a/.github/workflows/update-spec-integration-branch.yml b/.github/workflows/update-spec-integration-branch.yml index 8e11b75b7623..ce863020b463 100644 --- a/.github/workflows/update-spec-integration-branch.yml +++ b/.github/workflows/update-spec-integration-branch.yml @@ -114,7 +114,7 @@ jobs: env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: node scripts/gh/specs/ensure-pr/cli.mjs --spec=otel + run: node scripts/gh/specs/create-or-finalize-pr/cli.mjs --spec=otel report-failure: needs: update-integration-branch diff --git a/scripts/gh/specs/ensure-pr/cli.mjs b/scripts/gh/specs/create-or-finalize-pr/cli.mjs similarity index 92% rename from scripts/gh/specs/ensure-pr/cli.mjs rename to scripts/gh/specs/create-or-finalize-pr/cli.mjs index 3c1be8faca40..0c0b5944b330 100644 --- a/scripts/gh/specs/ensure-pr/cli.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/cli.mjs @@ -3,7 +3,7 @@ // workflow, according to the MODE picked by ../pick-branch. // // Usage: -// node scripts/gh/specs/ensure-pr/cli.mjs [--spec=] [--[no-]dry-run] +// node scripts/gh/specs/create-or-finalize-pr/cli.mjs [--spec=] [--[no-]dry-run] // // Flags: // -s, --spec= One of the keys defined in SPECS (e.g. `otel`, @@ -29,7 +29,11 @@ import { spawnSync } from 'node:child_process'; import { parseCliArgs, SPECS } from '../pick-branch/index.mjs'; -import { cliUsage, ensurePullRequest, readEnvInputs } from './index.mjs'; +import { + cliUsage, + createOrFinalizePullRequest, + readEnvInputs, +} from './index.mjs'; main(); @@ -62,7 +66,7 @@ function main() { ); console.log(`[input] MODE: ${mode}; VERSION: ${version}; BRANCH: ${branch}`); - const { action } = ensurePullRequest({ + const { action } = createOrFinalizePullRequest({ mode, repo, version, diff --git a/scripts/gh/specs/ensure-pr/index.mjs b/scripts/gh/specs/create-or-finalize-pr/index.mjs similarity index 92% rename from scripts/gh/specs/ensure-pr/index.mjs rename to scripts/gh/specs/create-or-finalize-pr/index.mjs index 25fd2f2924d0..39bfffa2a0f0 100644 --- a/scripts/gh/specs/ensure-pr/index.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -14,7 +14,7 @@ */ /** - * @typedef {Object} EnsurePrResult + * @typedef {Object} PrActionResult * @property {'none' * | 'created-draft' | 'bootstrapped-and-created-draft' * | 'created-release' | 'finalized' | 'title-synced' @@ -34,8 +34,8 @@ */ /** - * Ensure the integration branch has the pull request that the current MODE - * calls for: + * Create or finalize the integration branch's pull request, as the current + * MODE calls for: * * - **dev**: an open PR of any kind suffices; otherwise create the draft * integration PR, bootstrapping the branch with an empty commit when it has @@ -61,9 +61,9 @@ * @param {(args: string[]) => RunResult} input.runGit * Synchronous `git` runner. Receives the argv (without `git`). * @param {(msg: string) => void} [input.log] - * @returns {EnsurePrResult} + * @returns {PrActionResult} */ -export function ensurePullRequest({ +export function createOrFinalizePullRequest({ mode, repo, version, @@ -93,12 +93,29 @@ export function ensurePullRequest({ const pr = JSON.parse(list.stdout || '[]')[0] ?? null; return mode === 'dev' - ? ensureDevPr({ pr, repo, version, branch, dryRun, runGh, runGit, log }) - : ensureReleasePr({ pr, repo, version, branch, dryRun, runGh, log }); + ? createDevPrIfMissing({ + pr, + repo, + version, + branch, + dryRun, + runGh, + runGit, + log, + }) + : createOrFinalizeReleasePr({ + pr, + repo, + version, + branch, + dryRun, + runGh, + log, + }); } /** Dev mode: make sure the draft integration PR exists. */ -function ensureDevPr({ +function createDevPrIfMissing({ pr, repo, version, @@ -161,7 +178,15 @@ function ensureDevPr({ } /** Release mode: create the release PR, or finalize/re-sync the existing one. */ -function ensureReleasePr({ pr, repo, version, branch, dryRun, runGh, log }) { +function createOrFinalizeReleasePr({ + pr, + repo, + version, + branch, + dryRun, + runGh, + log, +}) { const title = `Update ${repo} version to ${version}`; const body = `Update ${repo} version to \`${version}\`.\n\nSee https://github.com/open-telemetry/${repo}/releases/tag/${version}.`; @@ -269,7 +294,7 @@ export function cliUsage() { 'pick-branch via $GITHUB_ENV) and expects the integration branch to be', 'checked out.', '', - 'Usage: node scripts/gh/specs/ensure-pr/cli.mjs \\', + 'Usage: node scripts/gh/specs/create-or-finalize-pr/cli.mjs \\', ' [--spec=] [--[no-]dry-run]', '', 'Options:', diff --git a/scripts/gh/specs/ensure-pr/inputs.test.mjs b/scripts/gh/specs/create-or-finalize-pr/inputs.test.mjs similarity index 95% rename from scripts/gh/specs/ensure-pr/inputs.test.mjs rename to scripts/gh/specs/create-or-finalize-pr/inputs.test.mjs index 9688645c1893..4f76b8b2ae48 100644 --- a/scripts/gh/specs/ensure-pr/inputs.test.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/inputs.test.mjs @@ -1,5 +1,5 @@ // Tests for readEnvInputs: strict validation of the MODE/VERSION/BRANCH -// environment values that the ensure-pr CLI consumes. +// environment values that the create-or-finalize-pr CLI consumes. import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; @@ -14,7 +14,7 @@ const VALID_ENV = { BRANCH: 'otelbot/spec-integration-v1.59.0-dev', }; -describe('ensure-pr: readEnvInputs', () => { +describe('create-or-finalize-pr: readEnvInputs', () => { test('accepts valid dev-mode inputs', () => { const inputs = readEnvInputs(VALID_ENV, SPEC); assert.deepEqual(inputs, { diff --git a/scripts/gh/specs/ensure-pr/pr.test.mjs b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs similarity index 91% rename from scripts/gh/specs/ensure-pr/pr.test.mjs rename to scripts/gh/specs/create-or-finalize-pr/pr.test.mjs index 485bb16bea41..d0419bc20035 100644 --- a/scripts/gh/specs/ensure-pr/pr.test.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs @@ -1,10 +1,10 @@ -// State-table tests for ensurePullRequest: the mode-aware PR create/finalize +// State-table tests for createOrFinalizePullRequest: the mode-aware PR create/finalize // helper driven by injected `gh` and `git` runners. import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { ensurePullRequest } from './index.mjs'; +import { createOrFinalizePullRequest } from './index.mjs'; const noLog = () => {}; @@ -47,11 +47,11 @@ const OPEN_READY_PR = { 'pr list': { stdout: '[{"number":10526,"isDraft":false}]', status: 0 }, }; -describe('ensure-pr: dev mode', () => { +describe('create-or-finalize-pr: dev mode', () => { test('PR already open: no-op', () => { const gh = makeFakeRunner(OPEN_DRAFT_PR); const git = makeFakeRunner(); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: false, @@ -69,7 +69,7 @@ describe('ensure-pr: dev mode', () => { const git = makeFakeRunner({ 'rev-list origin/main..HEAD': { stdout: 'abc123\n', status: 0 }, }); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: false, @@ -100,7 +100,7 @@ describe('ensure-pr: dev mode', () => { const git = makeFakeRunner({ 'rev-list origin/main..HEAD': { stdout: '', status: 0 }, }); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: false, @@ -126,7 +126,7 @@ describe('ensure-pr: dev mode', () => { }); }); -describe('ensure-pr: release mode', () => { +describe('create-or-finalize-pr: release mode', () => { const RELEASE_TITLE = 'Update opentelemetry-specification version to v1.59.0'; test('no PR: creates non-draft release PR', () => { @@ -134,7 +134,7 @@ describe('ensure-pr: release mode', () => { const git = makeFakeRunner({ 'rev-list origin/main..HEAD': { stdout: 'abc123\n', status: 0 }, }); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'release', dryRun: false, @@ -158,7 +158,7 @@ describe('ensure-pr: release mode', () => { test('open draft PR: one-time finalization (ready + title + body)', () => { const gh = makeFakeRunner(OPEN_DRAFT_PR); const git = makeFakeRunner(); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'release', dryRun: false, @@ -180,7 +180,7 @@ describe('ensure-pr: release mode', () => { test('open ready PR: re-syncs the title only', () => { const gh = makeFakeRunner(OPEN_READY_PR); const git = makeFakeRunner(); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'release', dryRun: false, @@ -207,7 +207,7 @@ describe('ensure-pr: release mode', () => { }, }); const git = makeFakeRunner(); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'release', dryRun: false, @@ -220,14 +220,14 @@ describe('ensure-pr: release mode', () => { }); }); -describe('ensure-pr: dry-run', () => { +describe('create-or-finalize-pr: dry-run', () => { test('dev mode: reads state but skips all writes', () => { const gh = makeFakeRunner(NO_PR); const git = makeFakeRunner({ 'rev-list origin/main..HEAD': { stdout: '', status: 0 }, }); const logs = []; - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: true, @@ -250,7 +250,7 @@ describe('ensure-pr: dry-run', () => { test('release mode with draft PR: skips finalize writes', () => { const gh = makeFakeRunner(OPEN_DRAFT_PR); const git = makeFakeRunner(); - const result = ensurePullRequest({ + const result = createOrFinalizePullRequest({ ...INPUT, mode: 'release', dryRun: true, @@ -263,13 +263,13 @@ describe('ensure-pr: dry-run', () => { }); }); -describe('ensure-pr: failure propagation', () => { +describe('create-or-finalize-pr: failure propagation', () => { test('throws when gh pr list fails', () => { const gh = makeFakeRunner({ 'pr list': { stdout: 'boom', status: 4 } }); const git = makeFakeRunner(); assert.throws( () => - ensurePullRequest({ + createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: false, @@ -291,7 +291,7 @@ describe('ensure-pr: failure propagation', () => { }); assert.throws( () => - ensurePullRequest({ + createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: false, @@ -311,7 +311,7 @@ describe('ensure-pr: failure propagation', () => { const git = makeFakeRunner(); assert.throws( () => - ensurePullRequest({ + createOrFinalizePullRequest({ ...INPUT, mode: 'release', dryRun: false, @@ -331,7 +331,7 @@ describe('ensure-pr: failure propagation', () => { }); assert.throws( () => - ensurePullRequest({ + createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: false, @@ -348,7 +348,7 @@ describe('ensure-pr: failure propagation', () => { const git = makeFakeRunner(); assert.throws( () => - ensurePullRequest({ + createOrFinalizePullRequest({ ...INPUT, mode: 'prod', dryRun: false, From 57b505f50ae73924baa7db532c667a4b8bf9497f Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 08:10:29 -0400 Subject: [PATCH 04/14] Simplify the create-or-finalize-pr result vocabulary (KISS) - Replaces the 10-value `{ action }` result with a plain `created | updated | unchanged` outcome string: the `would-*` variants only echoed the caller's own dry-run input, and the bootstrap distinction is an implementation detail that tests assert via the recorded `git` calls instead. - Collapses each dry-run/write branch pair into a single code path: one tense-neutral log line (`[dry-run]`-prefixed as needed) with only the writes guarded. --- .../gh/specs/create-or-finalize-pr/cli.mjs | 4 +- .../gh/specs/create-or-finalize-pr/index.mjs | 131 ++++++++---------- .../specs/create-or-finalize-pr/pr.test.mjs | 18 +-- 3 files changed, 67 insertions(+), 86 deletions(-) diff --git a/scripts/gh/specs/create-or-finalize-pr/cli.mjs b/scripts/gh/specs/create-or-finalize-pr/cli.mjs index 0c0b5944b330..73fd1319665f 100644 --- a/scripts/gh/specs/create-or-finalize-pr/cli.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/cli.mjs @@ -66,7 +66,7 @@ function main() { ); console.log(`[input] MODE: ${mode}; VERSION: ${version}; BRANCH: ${branch}`); - const { action } = createOrFinalizePullRequest({ + const outcome = createOrFinalizePullRequest({ mode, repo, version, @@ -76,7 +76,7 @@ function main() { runGit: (args) => run('git', args), }); - console.log(`[done] action: ${action}`); + console.log(`[done] outcome: ${outcome}${dryRun ? ' (dry-run)' : ''}`); } /** diff --git a/scripts/gh/specs/create-or-finalize-pr/index.mjs b/scripts/gh/specs/create-or-finalize-pr/index.mjs index 39bfffa2a0f0..d341752e193f 100644 --- a/scripts/gh/specs/create-or-finalize-pr/index.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -14,23 +14,17 @@ */ /** - * @typedef {Object} PrActionResult - * @property {'none' - * | 'created-draft' | 'bootstrapped-and-created-draft' - * | 'created-release' | 'finalized' | 'title-synced' - * | 'would-create-draft' | 'would-bootstrap-and-create-draft' - * | 'would-create-release' | 'would-finalize' | 'would-sync-title' - * } action - * What was done (or, in dry-run mode, would have been done): - * - `none`: PR already in the desired state. - * - `created-draft` / `bootstrapped-and-created-draft`: dev mode opened the - * draft integration PR (bootstrapping with an empty commit when the - * branch had none). - * - `created-release`: release mode opened a new non-draft release PR. - * - `finalized`: release mode promoted the dev-cycle draft PR (ready + - * title + body). - * - `title-synced`: release mode re-synced the title of an already-final - * PR, leaving the body alone. + * Outcome of a {@link createOrFinalizePullRequest} run, from the PR's point of + * view. In dry-run mode, the outcome that a write run would have produced. + * + * @typedef {'created' | 'updated' | 'unchanged'} PrOutcome + * - `created`: a new PR was opened — the draft integration PR in dev mode + * (bootstrapping the branch with an empty commit when it had none), the + * release PR in release mode. + * - `updated`: the existing PR was modified — release-mode one-time + * finalization of the draft (ready + title + body), or a title re-sync of + * an already-final PR. + * - `unchanged`: the PR was already in the desired state. */ /** @@ -48,7 +42,7 @@ * The body of the release PR is thus written exactly once. * * In dry-run mode the read-only PR/branch state queries still run, but all - * writes are skipped and reported via `would-*` actions. + * writes are skipped; log lines are prefixed with `[dry-run]`. * * @param {Object} input * @param {'dev'|'release'} input.mode @@ -61,7 +55,7 @@ * @param {(args: string[]) => RunResult} input.runGit * Synchronous `git` runner. Receives the argv (without `git`). * @param {(msg: string) => void} [input.log] - * @returns {PrActionResult} + * @returns {PrOutcome} */ export function createOrFinalizePullRequest({ mode, @@ -127,7 +121,7 @@ function createDevPrIfMissing({ }) { if (pr) { log(`PR #${pr.number} is already open for ${branch}; nothing to do.`); - return { action: 'none' }; + return 'unchanged'; } const revList = must( @@ -138,43 +132,35 @@ function createDevPrIfMissing({ const title = `DRAFT Update ${repo} to unreleased ${version}-dev`; const body = `This is a draft PR used for identifying issues integrating the latest (unreleased) [${repo}](https://github.com/open-telemetry/${repo}).`; - - if (dryRun) { - log( - `[dry-run] Would create draft PR "${title}"${needsBootstrap ? ' after bootstrapping the branch with an empty commit' : ''}.`, - ); - return { - action: needsBootstrap - ? 'would-bootstrap-and-create-draft' - : 'would-create-draft', - }; - } + const prefix = dryRun ? '[dry-run] ' : ''; if (needsBootstrap) { // Bootstrap this long-lived integration branch with an empty commit so // that PR creation succeeds (gh pr create fails when there are no // commits between main and the branch). - log(`Bootstrapping ${branch} with an empty commit.`); + log(`${prefix}Bootstrapping ${branch} with an empty commit.`); + if (!dryRun) { + must( + runGit([ + 'commit', + '--allow-empty', + '-m', + `Trigger PR creation for ${branch}`, + ]), + 'git commit', + ); + must(runGit(['push']), 'git push'); + } + } + + log(`${prefix}Creating draft PR "${title}".`); + if (!dryRun) { must( - runGit([ - 'commit', - '--allow-empty', - '-m', - `Trigger PR creation for ${branch}`, - ]), - 'git commit', + runGh(['pr', 'create', '--title', title, '--body', body, '--draft']), + 'gh pr create', ); - must(runGit(['push']), 'git push'); } - - must( - runGh(['pr', 'create', '--title', title, '--body', body, '--draft']), - 'gh pr create', - ); - log(`Created draft PR "${title}".`); - return { - action: needsBootstrap ? 'bootstrapped-and-created-draft' : 'created-draft', - }; + return 'created'; } /** Release mode: create the release PR, or finalize/re-sync the existing one. */ @@ -189,51 +175,46 @@ function createOrFinalizeReleasePr({ }) { const title = `Update ${repo} version to ${version}`; const body = `Update ${repo} version to \`${version}\`.\n\nSee https://github.com/open-telemetry/${repo}/releases/tag/${version}.`; + const prefix = dryRun ? '[dry-run] ' : ''; if (!pr) { - if (dryRun) { - log(`[dry-run] Would create release PR "${title}".`); - return { action: 'would-create-release' }; + log(`${prefix}Creating release PR "${title}".`); + if (!dryRun) { + must( + runGh(['pr', 'create', '--title', title, '--body', body]), + 'gh pr create', + ); } - must( - runGh(['pr', 'create', '--title', title, '--body', body]), - 'gh pr create', - ); - log(`Created release PR "${title}".`); - return { action: 'created-release' }; + return 'created'; } if (pr.isDraft) { // One-time finalization of the dev-cycle draft PR; the body is written // here and never again. - if (dryRun) { - log(`[dry-run] Would finalize draft PR #${pr.number} as "${title}".`); - return { action: 'would-finalize' }; + log(`${prefix}Finalizing PR #${pr.number} as "${title}".`); + if (!dryRun) { + must(runGh(['pr', 'ready', branch]), 'gh pr ready'); + must( + runGh(['pr', 'edit', branch, '--title', title, '--body', body]), + 'gh pr edit', + ); } - must(runGh(['pr', 'ready', branch]), 'gh pr ready'); - must( - runGh(['pr', 'edit', branch, '--title', title, '--body', body]), - 'gh pr edit', - ); - log(`Finalized PR #${pr.number} as "${title}".`); - return { action: 'finalized' }; + return 'updated'; } if (pr.title === title) { log(`PR #${pr.number} is already finalized as "${title}"; nothing to do.`); - return { action: 'none' }; + return 'unchanged'; } // Already finalized under another title (e.g. a newer release while this // PR awaits merge): re-sync the title, but leave the body alone to // preserve notes that maintainers may have added. - if (dryRun) { - log(`[dry-run] Would re-title PR #${pr.number} to "${title}".`); - return { action: 'would-sync-title' }; + log(`${prefix}Re-titling PR #${pr.number} to "${title}".`); + if (!dryRun) { + must(runGh(['pr', 'edit', branch, '--title', title]), 'gh pr edit'); } - must(runGh(['pr', 'edit', branch, '--title', title]), 'gh pr edit'); - log(`Re-titled PR #${pr.number} to "${title}".`); - return { action: 'title-synced' }; + return 'updated'; } /** diff --git a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs index d0419bc20035..44a764a1c6cd 100644 --- a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs @@ -59,7 +59,7 @@ describe('create-or-finalize-pr: dev mode', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'none'); + assert.equal(result, 'unchanged'); assert.equal(gh.calls.length, 1, 'only the pr-list call runs'); assert.equal(git.calls.length, 0, 'no git calls'); }); @@ -77,7 +77,7 @@ describe('create-or-finalize-pr: dev mode', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'created-draft'); + assert.equal(result, 'created'); const create = findCall(gh.calls, 'pr', 'create'); assert.ok(create, 'gh pr create is called'); assert.ok(create.includes('--draft'), 'PR is created as draft'); @@ -108,7 +108,7 @@ describe('create-or-finalize-pr: dev mode', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'bootstrapped-and-created-draft'); + assert.equal(result, 'created'); const commit = findCall(git.calls, 'commit'); assert.ok(commit, 'git commit is called'); assert.ok(commit.includes('--allow-empty'), 'commit is empty'); @@ -142,7 +142,7 @@ describe('create-or-finalize-pr: release mode', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'created-release'); + assert.equal(result, 'created'); const create = findCall(gh.calls, 'pr', 'create'); assert.ok(create, 'gh pr create is called'); assert.ok(!create.includes('--draft'), 'release PR is not a draft'); @@ -166,7 +166,7 @@ describe('create-or-finalize-pr: release mode', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'finalized'); + assert.equal(result, 'updated'); const ready = findCall(gh.calls, 'pr', 'ready'); assert.ok(ready, 'gh pr ready is called'); assert.ok(ready.includes(INPUT.branch), 'pr ready targets the branch'); @@ -188,7 +188,7 @@ describe('create-or-finalize-pr: release mode', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'title-synced'); + assert.equal(result, 'updated'); assert.ok(!findCall(gh.calls, 'pr', 'ready'), 'pr ready is not re-run'); const edit = findCall(gh.calls, 'pr', 'edit'); assert.ok(edit, 'gh pr edit is called'); @@ -215,7 +215,7 @@ describe('create-or-finalize-pr: release mode', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'none'); + assert.equal(result, 'unchanged'); assert.equal(gh.calls.length, 1, 'only the pr-list call runs'); }); }); @@ -235,7 +235,7 @@ describe('create-or-finalize-pr: dry-run', () => { runGit: git.run, log: (m) => logs.push(m), }); - assert.equal(result.action, 'would-bootstrap-and-create-draft'); + assert.equal(result, 'created', 'outcome a write run would produce'); assert.equal(gh.calls.length, 1, 'only the read-only pr-list call runs'); assert.ok( !findCall(git.calls, 'commit') && !findCall(git.calls, 'push'), @@ -258,7 +258,7 @@ describe('create-or-finalize-pr: dry-run', () => { runGit: git.run, log: noLog, }); - assert.equal(result.action, 'would-finalize'); + assert.equal(result, 'updated', 'outcome a write run would produce'); assert.equal(gh.calls.length, 1, 'only the read-only pr-list call runs'); }); }); From c9fe930677f51d1383ff97a1503f97d2fce3cacb Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 08:49:07 -0400 Subject: [PATCH 05/14] Make create-or-finalize-pr a directly executable command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moves cli.mjs up to `scripts/gh/specs/create-or-finalize-pr.mjs` (shebang + exec bit) so workflows and maintainers invoke it directly — `scripts/gh/specs/create-or-finalize-pr.mjs --spec=otel` — matching how the repo's shell commands are run; the same-named directory keeps the pure library and its tests. - Updates both integration workflows, the usage text, and doc comments accordingly. --- .../workflows/update-semconv-integration-branch.yml | 2 +- .github/workflows/update-spec-integration-branch.yml | 2 +- .../cli.mjs => create-or-finalize-pr.mjs} | 10 +++++----- scripts/gh/specs/create-or-finalize-pr/index.mjs | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) rename scripts/gh/specs/{create-or-finalize-pr/cli.mjs => create-or-finalize-pr.mjs} (90%) mode change 100644 => 100755 diff --git a/.github/workflows/update-semconv-integration-branch.yml b/.github/workflows/update-semconv-integration-branch.yml index 2179e34c33df..4d477ee4ed68 100644 --- a/.github/workflows/update-semconv-integration-branch.yml +++ b/.github/workflows/update-semconv-integration-branch.yml @@ -117,7 +117,7 @@ jobs: env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: node scripts/gh/specs/create-or-finalize-pr/cli.mjs --spec=semconv + run: scripts/gh/specs/create-or-finalize-pr.mjs --spec=semconv report-failure: needs: update-semconv-integration-branch diff --git a/.github/workflows/update-spec-integration-branch.yml b/.github/workflows/update-spec-integration-branch.yml index ce863020b463..544d3d276534 100644 --- a/.github/workflows/update-spec-integration-branch.yml +++ b/.github/workflows/update-spec-integration-branch.yml @@ -114,7 +114,7 @@ jobs: env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: node scripts/gh/specs/create-or-finalize-pr/cli.mjs --spec=otel + run: scripts/gh/specs/create-or-finalize-pr.mjs --spec=otel report-failure: needs: update-integration-branch diff --git a/scripts/gh/specs/create-or-finalize-pr/cli.mjs b/scripts/gh/specs/create-or-finalize-pr.mjs old mode 100644 new mode 100755 similarity index 90% rename from scripts/gh/specs/create-or-finalize-pr/cli.mjs rename to scripts/gh/specs/create-or-finalize-pr.mjs index 73fd1319665f..70c6ff4293e9 --- a/scripts/gh/specs/create-or-finalize-pr/cli.mjs +++ b/scripts/gh/specs/create-or-finalize-pr.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -// CLI entry point: create or finalize the integration-branch PR of a spec -// workflow, according to the MODE picked by ../pick-branch. +// Create or finalize the integration-branch PR of a spec workflow, according +// to the MODE picked by ./pick-branch. // // Usage: -// node scripts/gh/specs/create-or-finalize-pr/cli.mjs [--spec=] [--[no-]dry-run] +// scripts/gh/specs/create-or-finalize-pr.mjs [--spec=] [--[no-]dry-run] // // Flags: // -s, --spec= One of the keys defined in SPECS (e.g. `otel`, @@ -28,12 +28,12 @@ import { spawnSync } from 'node:child_process'; -import { parseCliArgs, SPECS } from '../pick-branch/index.mjs'; +import { parseCliArgs, SPECS } from './pick-branch/index.mjs'; import { cliUsage, createOrFinalizePullRequest, readEnvInputs, -} from './index.mjs'; +} from './create-or-finalize-pr/index.mjs'; main(); diff --git a/scripts/gh/specs/create-or-finalize-pr/index.mjs b/scripts/gh/specs/create-or-finalize-pr/index.mjs index d341752e193f..4bf0629a4898 100644 --- a/scripts/gh/specs/create-or-finalize-pr/index.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -3,7 +3,7 @@ // by ../pick-branch (dev vs release) and the current PR state. // // Side-effecting concerns (subprocess invocation, argv/env handling) live in -// ./cli.mjs. +// the command file, ../create-or-finalize-pr.mjs. /** * Result of an injected `gh`/`git` invocation. @@ -260,7 +260,7 @@ export function readEnvInputs(env, { abbr }) { } /** - * Help text for `cli.mjs --help`. + * Help text for `create-or-finalize-pr.mjs --help`. * * @returns {string} */ @@ -275,7 +275,7 @@ export function cliUsage() { 'pick-branch via $GITHUB_ENV) and expects the integration branch to be', 'checked out.', '', - 'Usage: node scripts/gh/specs/create-or-finalize-pr/cli.mjs \\', + 'Usage: scripts/gh/specs/create-or-finalize-pr.mjs \\', ' [--spec=] [--[no-]dry-run]', '', 'Options:', From 7bf6091569cfed57c4de8b253e7f86ea8f26501e Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 08:55:26 -0400 Subject: [PATCH 06/14] create-or-finalize-pr: make --help the home of CLI reference docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shrinks the command-file header to purpose + "run with --help": the help text now owns the flag and environment reference (DRY). - Moves cliUsage() from the lib into the command file — it is CLI surface, not library logic; folds the header's GH_TOKEN and branch-checkout notes into a new Environment section. - Prefers the space form for flag values: usage shows `--spec ` and the workflows invoke `--spec otel|semconv` (`--spec=` remains supported). --- .../update-semconv-integration-branch.yml | 2 +- .../update-spec-integration-branch.yml | 2 +- scripts/gh/specs/create-or-finalize-pr.mjs | 55 ++++++++++--------- .../gh/specs/create-or-finalize-pr/index.mjs | 27 --------- 4 files changed, 31 insertions(+), 55 deletions(-) diff --git a/.github/workflows/update-semconv-integration-branch.yml b/.github/workflows/update-semconv-integration-branch.yml index 4d477ee4ed68..25dfa8c811a4 100644 --- a/.github/workflows/update-semconv-integration-branch.yml +++ b/.github/workflows/update-semconv-integration-branch.yml @@ -117,7 +117,7 @@ jobs: env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: scripts/gh/specs/create-or-finalize-pr.mjs --spec=semconv + run: scripts/gh/specs/create-or-finalize-pr.mjs --spec semconv report-failure: needs: update-semconv-integration-branch diff --git a/.github/workflows/update-spec-integration-branch.yml b/.github/workflows/update-spec-integration-branch.yml index 544d3d276534..86bad2c0765f 100644 --- a/.github/workflows/update-spec-integration-branch.yml +++ b/.github/workflows/update-spec-integration-branch.yml @@ -114,7 +114,7 @@ jobs: env: # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows GH_TOKEN: ${{ steps.otelbot-token.outputs.token }} - run: scripts/gh/specs/create-or-finalize-pr.mjs --spec=otel + run: scripts/gh/specs/create-or-finalize-pr.mjs --spec otel report-failure: needs: update-integration-branch diff --git a/scripts/gh/specs/create-or-finalize-pr.mjs b/scripts/gh/specs/create-or-finalize-pr.mjs index 70c6ff4293e9..eb5f1d6c8ba8 100755 --- a/scripts/gh/specs/create-or-finalize-pr.mjs +++ b/scripts/gh/specs/create-or-finalize-pr.mjs @@ -1,36 +1,11 @@ #!/usr/bin/env node // Create or finalize the integration-branch PR of a spec workflow, according -// to the MODE picked by ./pick-branch. -// -// Usage: -// scripts/gh/specs/create-or-finalize-pr.mjs [--spec=] [--[no-]dry-run] -// -// Flags: -// -s, --spec= One of the keys defined in SPECS (e.g. `otel`, -// `semconv`). Defaults to `otel`. Determines the upstream -// repo name used in PR titles and bodies. -// --dry-run Skip side-effecting operations: no `git commit`/`git -// push`, no `gh pr create`/`ready`/`edit`. Read-only -// `git`/`gh` state queries still run. -// --no-dry-run Force writes even when running locally. -// Default: dry-run is ON unless GITHUB_ACTIONS=true. -// -h, --help Print usage and exit. -// -// Required environment: -// MODE, VERSION, BRANCH As written to $GITHUB_ENV by pick-branch; strictly -// validated (set them manually for local runs). -// GH_TOKEN Used by `gh`; needs PR write access when writes -// are enabled. -// -// The integration branch is expected to be checked out (with origin/main -// fetched) so that the bootstrap check `git rev-list origin/main..HEAD` is -// meaningful. +// to the MODE picked by ./pick-branch. Run with --help for usage. import { spawnSync } from 'node:child_process'; import { parseCliArgs, SPECS } from './pick-branch/index.mjs'; import { - cliUsage, createOrFinalizePullRequest, readEnvInputs, } from './create-or-finalize-pr/index.mjs'; @@ -100,3 +75,31 @@ function fatal(msg) { process.stderr.write(`${msg}\n`); process.exit(1); } + +/** Help text for `--help`. */ +function cliUsage() { + return [ + 'Create or finalize the pull request of an integration-branch workflow', + 'run. In dev mode, open the draft integration PR if none exists; in', + 'release mode, create the release PR, promote the existing draft, or', + 're-sync the title of an already-final PR.', + '', + 'Usage: scripts/gh/specs/create-or-finalize-pr.mjs \\', + ' [--spec ] [--[no-]dry-run]', + '', + 'Options:', + ' -s, --spec Selects the upstream spec (default: otel).', + ' --dry-run Skip writes (default when run locally).', + ' --no-dry-run Perform writes (default under GitHub Actions).', + ' -h, --help Show this help.', + '', + 'Environment:', + ' MODE, VERSION, BRANCH As written to $GITHUB_ENV by pick-branch;', + ' strictly validated (set manually for local runs).', + ' GH_TOKEN Used by `gh`; needs PR write access when writes', + ' are enabled.', + '', + 'Expects the integration branch to be checked out, with origin/main', + 'fetched (for the branch-has-commits check).', + ].join('\n'); +} diff --git a/scripts/gh/specs/create-or-finalize-pr/index.mjs b/scripts/gh/specs/create-or-finalize-pr/index.mjs index 4bf0629a4898..158a7dd0ffee 100644 --- a/scripts/gh/specs/create-or-finalize-pr/index.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -258,30 +258,3 @@ export function readEnvInputs(env, { abbr }) { } return { mode, version, branch }; } - -/** - * Help text for `create-or-finalize-pr.mjs --help`. - * - * @returns {string} - */ -export function cliUsage() { - return [ - 'Create or finalize the pull request of an integration-branch workflow', - 'run. In dev mode, open the draft integration PR if none exists; in', - 'release mode, create the release PR, promote the existing draft, or', - 're-sync the title of an already-final PR.', - '', - 'Reads MODE, VERSION and BRANCH from the environment (as written by', - 'pick-branch via $GITHUB_ENV) and expects the integration branch to be', - 'checked out.', - '', - 'Usage: scripts/gh/specs/create-or-finalize-pr.mjs \\', - ' [--spec=] [--[no-]dry-run]', - '', - 'Options:', - ' -s, --spec= Selects the upstream spec (default: otel).', - ' --dry-run Skip writes (default when run locally).', - ' --no-dry-run Perform writes (default under GitHub Actions).', - ' -h, --help Show this help.', - ].join('\n'); -} From 1fae7a0498083218fb82cca1b265e55b28a65f84 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 09:09:59 -0400 Subject: [PATCH 07/14] Site docs: cover the release flow in the CI workflows page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updates the spec-integration-branches section for the full release lifecycle: - Intro now covers dev-cycle draft PR → finalized release PR, and notes that auto-update-versions.yml excludes the spec repositories (also cross-referenced from the workflow table). - Documents pick-branch's MODE selection and the create-or-finalize-pr final step; run-modes section and examples now cover both helpers. - Adds a refcache entry for the new command's URL, which resolves once this PR lands (same approach as #9682 for pick-branch). --- content/en/site/build/ci-workflows.md | 78 ++++++++++++++++----------- static/refcache.json | 4 ++ 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/content/en/site/build/ci-workflows.md b/content/en/site/build/ci-workflows.md index 758a8a3dfcc7..e25f3b61ade0 100644 --- a/content/en/site/build/ci-workflows.md +++ b/content/en/site/build/ci-workflows.md @@ -383,9 +383,11 @@ usage lives in the [localization guide][localization-auto-merge]. ## Spec integration branches {#spec-integration-branches} -Two scheduled workflows track unreleased changes from upstream spec repositories -and keep a draft PR ("integration branch") current with the next development -version: +Two scheduled workflows own the site's update cycle for the upstream spec +repositories (which `auto-update-versions.yml` therefore excludes): between +releases, each workflow tracks unreleased upstream changes through a draft PR +("integration branch"); once upstream releases, it finalizes that branch and PR +into the release PR. | Workflow file | Upstream repository | Branch slug | | ----------------------------------------- | ----------------------------- | ----------- | @@ -397,23 +399,36 @@ version: [update-semconv-integration-branch.yml]: https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/update-semconv-integration-branch.yml -Both workflows delegate the "pick the next version + branch" step to a shared +Both workflows delegate the "pick the mode, version and branch" step to a shared Node helper, [scripts/gh/specs/pick-branch/cli.mjs][]. The helper: -- Reuses an existing `otelbot/-integration-vX.Y.Z-dev` branch when one - exists and the version has not yet been released; otherwise bumps the latest - release tag's minor version. -- Writes `VERSION` and `BRANCH` to `$GITHUB_ENV` for downstream steps. +- Selects the run's `MODE`: + - `dev` — the pinned version is the latest upstream release: track upstream + `main`, reusing the existing `otelbot/-integration-vX.Y.Z-dev` branch + when its version is unreleased, else bumping the latest release's minor + version. + - `release` — a newer upstream release exists: `VERSION` is its tag, which + downstream steps pin the submodule to. +- Writes `MODE`, `VERSION` and `BRANCH` to `$GITHUB_ENV` for downstream steps. - Opens a tracking issue (label `-integration-warning`, deduplicated) when it detects problems such as multiple stale integration branches. [scripts/gh/specs/pick-branch/cli.mjs]: https://github.com/open-telemetry/opentelemetry.io/tree/main/scripts/gh/specs/pick-branch +The final step, [scripts/gh/specs/create-or-finalize-pr.mjs][], creates or +finalizes the PR as `MODE` calls for: in dev mode, it opens the draft +integration PR if none exists; in release mode, it creates the release PR, +promotes the draft (ready + title + body), or re-syncs the title. The release +body is written only once, so notes added by maintainers are preserved. + +[scripts/gh/specs/create-or-finalize-pr.mjs]: + https://github.com/open-telemetry/opentelemetry.io/blob/main/scripts/gh/specs/create-or-finalize-pr.mjs + ### Run modes -The helper auto-selects between dry-run and write mode and prints a `[mode]` -banner explaining its choice: +Both helpers auto-select between dry-run and write mode and print a `[mode]` +banner explaining their choice: | Context | Default behavior | Override | | --------------------- | ---------------- | ------------------- | @@ -421,18 +436,19 @@ banner explaining its choice: | Local (anywhere else) | dry-run | pass `--no-dry-run` | Locally, dry-run still runs all read-only `git`/`gh` commands (so the issue -deduplication check executes), but skips writes. With `--no-dry-run` the helper -uses your local `gh` credentials; if `GITHUB_ENV` is unset, `VERSION`/`BRANCH` -are printed to stdout only. Try it: +deduplication check executes), but skips writes. With `--no-dry-run` the helpers +use your local `gh` credentials; if `GITHUB_ENV` is unset, pick-branch prints +`MODE`/`VERSION`/`BRANCH` to stdout only — export these to feed a local +create-or-finalize-pr run. Try it: ```sh -node scripts/gh/specs/pick-branch/cli.mjs --spec=otel -node scripts/gh/specs/pick-branch/cli.mjs --spec=semconv --no-dry-run -node scripts/gh/specs/pick-branch/cli.mjs --help +node scripts/gh/specs/pick-branch/cli.mjs --spec otel +node scripts/gh/specs/pick-branch/cli.mjs --spec semconv --no-dry-run +scripts/gh/specs/create-or-finalize-pr.mjs --help ``` -Pure logic and CLI argument parsing live in `index.mjs` and are covered by -`*.test.mjs` files in the same folder (`npm run test:local-tools` to run them). +Pure logic lives in each helper's `index.mjs` and is covered by `*.test.mjs` +files in the same folder (`npm run test:local-tools` to run them). ## Workflow failure reporting {#workflow-failure-reporting} @@ -451,19 +467,19 @@ logic lives in [scripts/gh/report-failure/][report-failure-script] The repository includes several other workflows: -| Workflow | Purpose | -| -------------------------- | -------------------------------------------------------------------------------------- | -| `check-links.yml` | Sharded link checking using htmltest, plus a non-blocking [Lychee][lychee-pilot] pilot | -| `check-text.yml` | Textlint terminology checks | -| `check-i18n.yml` | Localization front matter validation | -| `check-spelling.yml` | Spell checking | -| `test.yml` | Test (excludes `test:base`) | -| `auto-update-registry.yml` | Auto-update registry package versions | -| `auto-update-versions.yml` | Auto-update OTel component versions | -| `build-dev.yml` | Development build and preview | -| `lint-scripts.yml` | ShellCheck linting for `.github/scripts/` | -| `label-manager.yml` | PR labels (component labels & approval flow) | -| `component-owners.yml` | Assign reviewers based on component ownership | +| Workflow | Purpose | +| -------------------------- | -------------------------------------------------------------------------------------------- | +| `check-links.yml` | Sharded link checking using htmltest, plus a non-blocking [Lychee][lychee-pilot] pilot | +| `check-text.yml` | Textlint terminology checks | +| `check-i18n.yml` | Localization front matter validation | +| `check-spelling.yml` | Spell checking | +| `test.yml` | Test (excludes `test:base`) | +| `auto-update-registry.yml` | Auto-update registry package versions | +| `auto-update-versions.yml` | Auto-update OTel component versions (except [spec repositories](#spec-integration-branches)) | +| `build-dev.yml` | Development build and preview | +| `lint-scripts.yml` | ShellCheck linting for `.github/scripts/` | +| `label-manager.yml` | PR labels (component labels & approval flow) | +| `component-owners.yml` | Assign reviewers based on component ownership | [lychee-pilot]: ../npm-scripts/#notes diff --git a/static/refcache.json b/static/refcache.json index 03c6496007da..8f9672889392 100644 --- a/static/refcache.json +++ b/static/refcache.json @@ -14679,6 +14679,10 @@ "StatusCode": 206, "LastSeen": "2026-07-04T06:46:14.895591302Z" }, + "https://github.com/open-telemetry/opentelemetry.io/blob/main/scripts/gh/specs/create-or-finalize-pr.mjs": { + "StatusCode": 206, + "LastSeen": "2026-07-09T13:00:00.000000000Z" + }, "https://github.com/open-telemetry/opentelemetry.io/branches/all?query=semconv-integration": { "StatusCode": 206, "LastSeen": "2026-07-05T06:52:17.295664434Z" From 23594e57b874e4cbc7a86b8f3954785d83aa2f13 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 09:49:56 -0400 Subject: [PATCH 08/14] Finalize PRs edit-first so a failed run can self-heal Review feedback: during draft finalization, `gh pr ready` ran before `gh pr edit`. If `edit` failed after `ready` succeeded, the next run saw a non-draft PR and only re-synced the title -- the release body was never written, and the run reported success. Write the title and body first: if `ready` fails, the PR remains a draft and the next run redoes the finalization idempotently. Test improvements from the same review: - Assert the edit-before-ready order, and add a regression test that a failed edit leaves the PR a draft (`pr ready` unreached). - Make the bootstrap-before-`pr create` ordering assertion real: the fake runners can now share an ordered call log; the previous assertion (`git.calls.length > 1`) did not check order. - Reword assertion messages to be positively affirmative. --- .../gh/specs/create-or-finalize-pr/index.mjs | 9 ++- .../specs/create-or-finalize-pr/pr.test.mjs | 77 +++++++++++++++---- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/scripts/gh/specs/create-or-finalize-pr/index.mjs b/scripts/gh/specs/create-or-finalize-pr/index.mjs index 158a7dd0ffee..e6d37b728a9e 100644 --- a/scripts/gh/specs/create-or-finalize-pr/index.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -35,11 +35,11 @@ * integration PR, bootstrapping the branch with an empty commit when it has * no commits over main (`gh pr create` fails otherwise). * - **release**: no PR → create the non-draft release PR; draft PR → one-time - * finalization (`gh pr ready` + title + body); non-draft PR → re-sync the + * finalization (title + body + `gh pr ready`); non-draft PR → re-sync the * title only (e.g. a newer release while the PR awaits merge), preserving * any notes maintainers may have added to the body. * - * The body of the release PR is thus written exactly once. + * The body of the release PR is thus written only during finalization. * * In dry-run mode the read-only PR/branch state queries still run, but all * writes are skipped; log lines are prefixed with `[dry-run]`. @@ -190,14 +190,15 @@ function createOrFinalizeReleasePr({ if (pr.isDraft) { // One-time finalization of the dev-cycle draft PR; the body is written - // here and never again. + // only here. Edit before ready: should `ready` fail, the PR remains a + // draft and the next run redoes the finalization. log(`${prefix}Finalizing PR #${pr.number} as "${title}".`); if (!dryRun) { - must(runGh(['pr', 'ready', branch]), 'gh pr ready'); must( runGh(['pr', 'edit', branch, '--title', title, '--body', body]), 'gh pr edit', ); + must(runGh(['pr', 'ready', branch]), 'gh pr ready'); } return 'updated'; } diff --git a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs index 44a764a1c6cd..e5b953897042 100644 --- a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs @@ -11,12 +11,15 @@ const noLog = () => {}; /** * Build a fake runner that records calls and serves canned responses keyed by * the first two argv tokens (e.g. `pr list`, `pr create`). Default response is - * `{ stdout: '', status: 0 }`. + * `{ stdout: '', status: 0 }`. When given a shared `seq` array and a `tool` + * tag, each call is also appended to `seq` as `[tool, ...argv]` so that + * cross-runner call order can be asserted. */ -function makeFakeRunner(responses = {}) { +function makeFakeRunner(responses = {}, seq = undefined, tool = '') { const calls = []; const run = (args) => { calls.push(args); + seq?.push([tool, ...args]); const key = args.slice(0, 2).join(' '); return responses[key] ?? { stdout: '', status: 0 }; }; @@ -28,6 +31,13 @@ function findCall(calls, ...prefix) { return calls.find((args) => prefix.every((token, i) => args[i] === token)); } +/** Index in `calls` of the first call matching `prefix`; fails if absent. */ +function callIndex(calls, ...prefix) { + const i = calls.findIndex((args) => prefix.every((t, j) => args[j] === t)); + assert.ok(i >= 0, `${prefix.join(' ')} is called`); + return i; +} + /** Value following `flag` in `args`. */ function flagValue(args, flag) { return args[args.indexOf(flag) + 1]; @@ -61,7 +71,7 @@ describe('create-or-finalize-pr: dev mode', () => { }); assert.equal(result, 'unchanged'); assert.equal(gh.calls.length, 1, 'only the pr-list call runs'); - assert.equal(git.calls.length, 0, 'no git calls'); + assert.equal(git.calls.length, 0, 'git is unused'); }); test('no PR, branch has commits: creates draft PR', () => { @@ -91,15 +101,18 @@ describe('create-or-finalize-pr: dev mode', () => { ); assert.ok( !findCall(git.calls, 'commit'), - 'no bootstrap commit when branch has commits', + 'a branch with commits skips the bootstrap commit', ); }); test('no PR, branch even with main: bootstraps with an empty commit', () => { - const gh = makeFakeRunner(NO_PR); - const git = makeFakeRunner({ - 'rev-list origin/main..HEAD': { stdout: '', status: 0 }, - }); + const seq = []; + const gh = makeFakeRunner(NO_PR, seq, 'gh'); + const git = makeFakeRunner( + { 'rev-list origin/main..HEAD': { stdout: '', status: 0 } }, + seq, + 'git', + ); const result = createOrFinalizePullRequest({ ...INPUT, mode: 'dev', @@ -117,11 +130,9 @@ describe('create-or-finalize-pr: dev mode', () => { `Trigger PR creation for ${INPUT.branch}`, ); assert.ok(findCall(git.calls, 'push'), 'bootstrap commit is pushed'); - assert.ok(findCall(gh.calls, 'pr', 'create'), 'draft PR is created'); - // Bootstrap must happen before PR creation. assert.ok( - git.calls.length > 1, - 'git bootstrap calls precede the gh pr create call', + callIndex(seq, 'git', 'push') < callIndex(seq, 'gh', 'pr', 'create'), + 'bootstrap commit is pushed before gh pr create runs', ); }); }); @@ -155,7 +166,7 @@ describe('create-or-finalize-pr: release mode', () => { ); }); - test('open draft PR: one-time finalization (ready + title + body)', () => { + test('open draft PR: one-time finalization (title + body, then ready)', () => { const gh = makeFakeRunner(OPEN_DRAFT_PR); const git = makeFakeRunner(); const result = createOrFinalizePullRequest({ @@ -174,7 +185,11 @@ describe('create-or-finalize-pr: release mode', () => { assert.ok(edit, 'gh pr edit is called'); assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); assert.ok(edit.includes('--body'), 'finalization writes the body once'); - assert.equal(git.calls.length, 0, 'no git calls'); + assert.ok( + callIndex(gh.calls, 'pr', 'edit') < callIndex(gh.calls, 'pr', 'ready'), + 'title and body are written while the PR is still a draft', + ); + assert.equal(git.calls.length, 0, 'git is unused'); }); test('open ready PR: re-syncs the title only', () => { @@ -189,7 +204,10 @@ describe('create-or-finalize-pr: release mode', () => { log: noLog, }); assert.equal(result, 'updated'); - assert.ok(!findCall(gh.calls, 'pr', 'ready'), 'pr ready is not re-run'); + assert.ok( + !findCall(gh.calls, 'pr', 'ready'), + 'an already-ready PR skips pr ready', + ); const edit = findCall(gh.calls, 'pr', 'edit'); assert.ok(edit, 'gh pr edit is called'); assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); @@ -239,7 +257,7 @@ describe('create-or-finalize-pr: dry-run', () => { assert.equal(gh.calls.length, 1, 'only the read-only pr-list call runs'); assert.ok( !findCall(git.calls, 'commit') && !findCall(git.calls, 'push'), - 'no git writes in dry-run', + 'dry-run keeps git read-only', ); assert.ok( logs.some((m) => /\[dry-run\]/.test(m)), @@ -323,6 +341,31 @@ describe('create-or-finalize-pr: failure propagation', () => { ); }); + test('throws when gh pr edit fails during finalization, before pr ready', () => { + const gh = makeFakeRunner({ + ...OPEN_DRAFT_PR, + 'pr edit': { stdout: 'boom', status: 1 }, + }); + const git = makeFakeRunner(); + assert.throws( + () => + createOrFinalizePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /gh pr edit failed/, + ); + // The PR must remain a draft so that the next run redoes the finalization. + assert.ok( + !findCall(gh.calls, 'pr', 'ready'), + 'pr ready runs only after a successful edit', + ); + }); + test('throws when git bootstrap commit fails', () => { const gh = makeFakeRunner(NO_PR); const git = makeFakeRunner({ @@ -358,6 +401,6 @@ describe('create-or-finalize-pr: failure propagation', () => { }), /unexpected mode: prod/, ); - assert.equal(gh.calls.length, 0, 'no calls are made on invalid input'); + assert.equal(gh.calls.length, 0, 'invalid mode fails before any call'); }); }); From 51154a4a40bb819e945b9ece6fca835f58fbb284 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 09:51:00 -0400 Subject: [PATCH 09/14] Prefer the space form of --spec in usage text and workflows Review feedback: the new create-or-finalize-pr command documents and is invoked with `--spec `, while pick-branch's usage text and workflow invocations still showed `--spec=`. Align on the space form; the `=` form remains supported and tested. --- .github/workflows/update-semconv-integration-branch.yml | 2 +- .github/workflows/update-spec-integration-branch.yml | 2 +- scripts/gh/specs/pick-branch/cli.mjs | 4 ++-- scripts/gh/specs/pick-branch/index.mjs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/update-semconv-integration-branch.yml b/.github/workflows/update-semconv-integration-branch.yml index 25dfa8c811a4..e56f3080d5dd 100644 --- a/.github/workflows/update-semconv-integration-branch.yml +++ b/.github/workflows/update-semconv-integration-branch.yml @@ -39,7 +39,7 @@ jobs: - name: Pick integration branch env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/gh/specs/pick-branch/cli.mjs --spec=semconv + run: node scripts/gh/specs/pick-branch/cli.mjs --spec semconv - name: Checkout or create branch run: | diff --git a/.github/workflows/update-spec-integration-branch.yml b/.github/workflows/update-spec-integration-branch.yml index 86bad2c0765f..62847c34bffb 100644 --- a/.github/workflows/update-spec-integration-branch.yml +++ b/.github/workflows/update-spec-integration-branch.yml @@ -38,7 +38,7 @@ jobs: - name: Pick integration branch env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/gh/specs/pick-branch/cli.mjs --spec=otel + run: node scripts/gh/specs/pick-branch/cli.mjs --spec otel - name: Checkout or create branch run: | diff --git a/scripts/gh/specs/pick-branch/cli.mjs b/scripts/gh/specs/pick-branch/cli.mjs index 4bcf26849234..f7424194ec2f 100644 --- a/scripts/gh/specs/pick-branch/cli.mjs +++ b/scripts/gh/specs/pick-branch/cli.mjs @@ -2,10 +2,10 @@ // CLI entry point: pick the integration branch / version for a spec workflow. // // Usage: -// node scripts/gh/specs/pick-branch/cli.mjs [--spec=] [--[no-]dry-run] +// node scripts/gh/specs/pick-branch/cli.mjs [--spec ] [--[no-]dry-run] // // Flags: -// -s, --spec= One of the keys defined in SPECS (e.g. `otel`, +// -s, --spec One of the keys defined in SPECS (e.g. `otel`, // `semconv`). Defaults to `otel`. Determines the upstream // repo and branch slug. // --dry-run Skip side-effecting operations: do NOT write to diff --git a/scripts/gh/specs/pick-branch/index.mjs b/scripts/gh/specs/pick-branch/index.mjs index d653d4b9fb58..cec49ede5360 100644 --- a/scripts/gh/specs/pick-branch/index.mjs +++ b/scripts/gh/specs/pick-branch/index.mjs @@ -408,10 +408,10 @@ export function cliUsage() { 'warnings.', '', 'Usage: node scripts/gh/specs/pick-branch/cli.mjs \\', - ` [--spec=<${allowed}>] [--[no-]dry-run]`, + ` [--spec <${allowed}>] [--[no-]dry-run]`, '', 'Options:', - ` -s, --spec=<${allowed}> Selects the upstream spec (default: otel).`, + ` -s, --spec <${allowed}> Selects the upstream spec (default: otel).`, ' --dry-run Skip writes (default when run locally).', ' --no-dry-run Perform writes (default under GitHub Actions).', ' -h, --help Show this help.', From 6824444f976bf5c489dec52ad5153fa085802e72 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 10:26:41 -0400 Subject: [PATCH 10/14] Site docs: trim helper mechanics from the CI workflows page The finalize mechanics quoted on the page (ready/edit ordering) went stale within one review cycle -- a sign the page owned too much detail. Keep the what/why and the step contracts (MODE meaning, env handoff, maintainer notes preserved); the mechanics live in the helpers' docstrings and --help. --- content/en/site/build/ci-workflows.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/content/en/site/build/ci-workflows.md b/content/en/site/build/ci-workflows.md index e25f3b61ade0..aece6e08eccc 100644 --- a/content/en/site/build/ci-workflows.md +++ b/content/en/site/build/ci-workflows.md @@ -402,13 +402,8 @@ into the release PR. Both workflows delegate the "pick the mode, version and branch" step to a shared Node helper, [scripts/gh/specs/pick-branch/cli.mjs][]. The helper: -- Selects the run's `MODE`: - - `dev` — the pinned version is the latest upstream release: track upstream - `main`, reusing the existing `otelbot/-integration-vX.Y.Z-dev` branch - when its version is unreleased, else bumping the latest release's minor - version. - - `release` — a newer upstream release exists: `VERSION` is its tag, which - downstream steps pin the submodule to. +- Selects the run's `MODE`: `dev` while the version pinned on main is the latest + upstream release, `release` once a newer release exists. - Writes `MODE`, `VERSION` and `BRANCH` to `$GITHUB_ENV` for downstream steps. - Opens a tracking issue (label `-integration-warning`, deduplicated) when it detects problems such as multiple stale integration branches. @@ -417,10 +412,9 @@ Node helper, [scripts/gh/specs/pick-branch/cli.mjs][]. The helper: https://github.com/open-telemetry/opentelemetry.io/tree/main/scripts/gh/specs/pick-branch The final step, [scripts/gh/specs/create-or-finalize-pr.mjs][], creates or -finalizes the PR as `MODE` calls for: in dev mode, it opens the draft -integration PR if none exists; in release mode, it creates the release PR, -promotes the draft (ready + title + body), or re-syncs the title. The release -body is written only once, so notes added by maintainers are preserved. +finalizes the PR as `MODE` calls for: in dev mode it opens the draft integration +PR if missing; in release mode it creates or finalizes the release PR, +preserving any notes maintainers have added to the PR body. [scripts/gh/specs/create-or-finalize-pr.mjs]: https://github.com/open-telemetry/opentelemetry.io/blob/main/scripts/gh/specs/create-or-finalize-pr.mjs From 3a4ee505b5d27e6c2bb6f3639622cf3017ada3b1 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 10:26:42 -0400 Subject: [PATCH 11/14] Log the created PR's URL The command wrapper pipes gh's stdout, so the URL that `gh pr create` prints was captured and dropped -- an observability regression from the inline workflow step, which streamed it to the log. --- .../gh/specs/create-or-finalize-pr/index.mjs | 17 +++++++++++++++-- .../specs/create-or-finalize-pr/pr.test.mjs | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/gh/specs/create-or-finalize-pr/index.mjs b/scripts/gh/specs/create-or-finalize-pr/index.mjs index e6d37b728a9e..8378495bb2ba 100644 --- a/scripts/gh/specs/create-or-finalize-pr/index.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -155,10 +155,11 @@ function createDevPrIfMissing({ log(`${prefix}Creating draft PR "${title}".`); if (!dryRun) { - must( + const created = must( runGh(['pr', 'create', '--title', title, '--body', body, '--draft']), 'gh pr create', ); + logUrl(created, log); } return 'created'; } @@ -180,10 +181,11 @@ function createOrFinalizeReleasePr({ if (!pr) { log(`${prefix}Creating release PR "${title}".`); if (!dryRun) { - must( + const created = must( runGh(['pr', 'create', '--title', title, '--body', body]), 'gh pr create', ); + logUrl(created, log); } return 'created'; } @@ -218,6 +220,17 @@ function createOrFinalizeReleasePr({ return 'updated'; } +/** + * Log a created PR's URL (`gh pr create` prints it on stdout). + * + * @param {RunResult} result + * @param {(msg: string) => void} log + */ +function logUrl(result, log) { + const url = result.stdout.trim(); + if (url) log(url); +} + /** * @param {RunResult} result * @param {string} what Human-readable command label, e.g. `gh pr create`. diff --git a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs index e5b953897042..e14b63d2e909 100644 --- a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs @@ -50,6 +50,7 @@ const INPUT = { }; const NO_PR = { 'pr list': { stdout: '[]', status: 0 } }; +const PR_URL = 'https://github.com/open-telemetry/opentelemetry.io/pull/12345'; const OPEN_DRAFT_PR = { 'pr list': { stdout: '[{"number":10526,"isDraft":true}]', status: 0 }, }; @@ -75,17 +76,21 @@ describe('create-or-finalize-pr: dev mode', () => { }); test('no PR, branch has commits: creates draft PR', () => { - const gh = makeFakeRunner(NO_PR); + const gh = makeFakeRunner({ + ...NO_PR, + 'pr create': { stdout: `${PR_URL}\n`, status: 0 }, + }); const git = makeFakeRunner({ 'rev-list origin/main..HEAD': { stdout: 'abc123\n', status: 0 }, }); + const logs = []; const result = createOrFinalizePullRequest({ ...INPUT, mode: 'dev', dryRun: false, runGh: gh.run, runGit: git.run, - log: noLog, + log: (m) => logs.push(m), }); assert.equal(result, 'created'); const create = findCall(gh.calls, 'pr', 'create'); @@ -103,6 +108,7 @@ describe('create-or-finalize-pr: dev mode', () => { !findCall(git.calls, 'commit'), 'a branch with commits skips the bootstrap commit', ); + assert.ok(logs.includes(PR_URL), 'the created PR URL is logged'); }); test('no PR, branch even with main: bootstraps with an empty commit', () => { @@ -141,17 +147,21 @@ describe('create-or-finalize-pr: release mode', () => { const RELEASE_TITLE = 'Update opentelemetry-specification version to v1.59.0'; test('no PR: creates non-draft release PR', () => { - const gh = makeFakeRunner(NO_PR); + const gh = makeFakeRunner({ + ...NO_PR, + 'pr create': { stdout: `${PR_URL}\n`, status: 0 }, + }); const git = makeFakeRunner({ 'rev-list origin/main..HEAD': { stdout: 'abc123\n', status: 0 }, }); + const logs = []; const result = createOrFinalizePullRequest({ ...INPUT, mode: 'release', dryRun: false, runGh: gh.run, runGit: git.run, - log: noLog, + log: (m) => logs.push(m), }); assert.equal(result, 'created'); const create = findCall(gh.calls, 'pr', 'create'); @@ -164,6 +174,7 @@ describe('create-or-finalize-pr: release mode', () => { body, /https:\/\/github\.com\/open-telemetry\/opentelemetry-specification\/releases\/tag\/v1\.59\.0/, ); + assert.ok(logs.includes(PR_URL), 'the created PR URL is logged'); }); test('open draft PR: one-time finalization (title + body, then ready)', () => { From c40b8bfe483c164b6e04a45576a2c62badbced30 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 10:49:17 -0400 Subject: [PATCH 12/14] Rewrite only automation-written PR text when finalizing Release-mode finalization becomes three independent, idempotent fixups -- title sync, body sync, draft-to-ready -- governed by one ownership rule: the automation rewrites only text it itself wrote (the dev or release title/body, for any version); once a maintainer edits the title or the body, that field is theirs and is left alone. This heals PRs that previous runs could strand (e.g. a draft manually marked ready kept its draft body forever), re-syncs the stale body along with the title when a newer release lands, stops reverting deliberate maintainer retitles, and lets body notes added during the dev cycle survive finalization. Addresses a bugbot finding on the release-flow PR. --- content/en/site/build/ci-workflows.md | 5 +- .../gh/specs/create-or-finalize-pr/index.mjs | 153 +++++++++++++---- .../specs/create-or-finalize-pr/pr.test.mjs | 161 +++++++++++++----- 3 files changed, 244 insertions(+), 75 deletions(-) diff --git a/content/en/site/build/ci-workflows.md b/content/en/site/build/ci-workflows.md index aece6e08eccc..46f2c5971f39 100644 --- a/content/en/site/build/ci-workflows.md +++ b/content/en/site/build/ci-workflows.md @@ -413,8 +413,9 @@ Node helper, [scripts/gh/specs/pick-branch/cli.mjs][]. The helper: The final step, [scripts/gh/specs/create-or-finalize-pr.mjs][], creates or finalizes the PR as `MODE` calls for: in dev mode it opens the draft integration -PR if missing; in release mode it creates or finalizes the release PR, -preserving any notes maintainers have added to the PR body. +PR if missing; in release mode it creates or finalizes the release PR. It only +rewrites PR text that the automation itself wrote: a maintainer-edited title or +body is left alone. [scripts/gh/specs/create-or-finalize-pr.mjs]: https://github.com/open-telemetry/opentelemetry.io/blob/main/scripts/gh/specs/create-or-finalize-pr.mjs diff --git a/scripts/gh/specs/create-or-finalize-pr/index.mjs b/scripts/gh/specs/create-or-finalize-pr/index.mjs index 8378495bb2ba..6a0907f41e88 100644 --- a/scripts/gh/specs/create-or-finalize-pr/index.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -21,9 +21,9 @@ * - `created`: a new PR was opened — the draft integration PR in dev mode * (bootstrapping the branch with an empty commit when it had none), the * release PR in release mode. - * - `updated`: the existing PR was modified — release-mode one-time - * finalization of the draft (ready + title + body), or a title re-sync of - * an already-final PR. + * - `updated`: the existing PR was modified — in release mode, whichever + * combination of title sync, body sync, and draft→ready promotion the PR + * needed. * - `unchanged`: the PR was already in the desired state. */ @@ -34,12 +34,14 @@ * - **dev**: an open PR of any kind suffices; otherwise create the draft * integration PR, bootstrapping the branch with an empty commit when it has * no commits over main (`gh pr create` fails otherwise). - * - **release**: no PR → create the non-draft release PR; draft PR → one-time - * finalization (title + body + `gh pr ready`); non-draft PR → re-sync the - * title only (e.g. a newer release while the PR awaits merge), preserving - * any notes maintainers may have added to the body. + * - **release**: no PR → create the non-draft release PR; open PR → apply + * whichever fixups it needs: sync the title and the body to the release + * form, and promote a draft to ready (edit before ready, so a failed run + * leaves a draft that the next run re-finalizes). * - * The body of the release PR is thus written only during finalization. + * The automation rewrites only text it itself wrote — the dev or release + * title/body, for any version. Once a maintainer edits the title or the body, + * that field is theirs and is left alone. * * In dry-run mode the read-only PR/branch state queries still run, but all * writes are skipped; log lines are prefixed with `[dry-run]`. @@ -80,7 +82,7 @@ export function createOrFinalizePullRequest({ '--head', branch, '--json', - 'number,isDraft,title', + 'number,isDraft,title,body', ]), 'gh pr list', ); @@ -130,8 +132,7 @@ function createDevPrIfMissing({ ); const needsBootstrap = revList.stdout.trim() === ''; - const title = `DRAFT Update ${repo} to unreleased ${version}-dev`; - const body = `This is a draft PR used for identifying issues integrating the latest (unreleased) [${repo}](https://github.com/open-telemetry/${repo}).`; + const { title, body } = devPrText(repo, version); const prefix = dryRun ? '[dry-run] ' : ''; if (needsBootstrap) { @@ -164,7 +165,7 @@ function createDevPrIfMissing({ return 'created'; } -/** Release mode: create the release PR, or finalize/re-sync the existing one. */ +/** Release mode: create the release PR, or apply the fixups the open PR needs. */ function createOrFinalizeReleasePr({ pr, repo, @@ -174,8 +175,7 @@ function createOrFinalizeReleasePr({ runGh, log, }) { - const title = `Update ${repo} version to ${version}`; - const body = `Update ${repo} version to \`${version}\`.\n\nSee https://github.com/open-telemetry/${repo}/releases/tag/${version}.`; + const { title, body } = releasePrText(repo, version); const prefix = dryRun ? '[dry-run] ' : ''; if (!pr) { @@ -190,36 +190,121 @@ function createOrFinalizeReleasePr({ return 'created'; } - if (pr.isDraft) { - // One-time finalization of the dev-cycle draft PR; the body is written - // only here. Edit before ready: should `ready` fail, the PR remains a - // draft and the next run redoes the finalization. - log(`${prefix}Finalizing PR #${pr.number} as "${title}".`); - if (!dryRun) { - must( - runGh(['pr', 'edit', branch, '--title', title, '--body', body]), - 'gh pr edit', - ); - must(runGh(['pr', 'ready', branch]), 'gh pr ready'); - } - return 'updated'; + // Independent, idempotent fixups; see the ownership rule in the docs of + // createOrFinalizePullRequest. + const editArgs = []; + if (normalize(pr.title) !== title && isAutomationTitle(pr.title, repo)) { + editArgs.push('--title', title); + } + if (normalize(pr.body) !== body && isAutomationBody(pr.body, repo)) { + editArgs.push('--body', body); } - if (pr.title === title) { - log(`PR #${pr.number} is already finalized as "${title}"; nothing to do.`); + if (!editArgs.length && !pr.isDraft) { + log(`PR #${pr.number} is already finalized; nothing to do.`); return 'unchanged'; } - // Already finalized under another title (e.g. a newer release while this - // PR awaits merge): re-sync the title, but leave the body alone to - // preserve notes that maintainers may have added. - log(`${prefix}Re-titling PR #${pr.number} to "${title}".`); + const fixes = [ + editArgs.includes('--title') && 'title', + editArgs.includes('--body') && 'body', + pr.isDraft && 'ready', + ] + .filter(Boolean) + .join(' + '); + log(`${prefix}Finalizing PR #${pr.number} for ${version} (${fixes}).`); + if (!dryRun) { - must(runGh(['pr', 'edit', branch, '--title', title]), 'gh pr edit'); + // Edit before ready: should `ready` fail, the PR remains a draft and the + // next run redoes the finalization. + if (editArgs.length) { + must(runGh(['pr', 'edit', branch, ...editArgs]), 'gh pr edit'); + } + if (pr.isDraft) { + must(runGh(['pr', 'ready', branch]), 'gh pr ready'); + } } return 'updated'; } +/** + * Title and body of the dev-cycle draft integration PR. + * + * @param {string} repo @param {string} version + * @returns {{ title: string, body: string }} + */ +function devPrText(repo, version) { + return { + title: `DRAFT Update ${repo} to unreleased ${version}-dev`, + body: `This is a draft PR used for identifying issues integrating the latest (unreleased) [${repo}](https://github.com/open-telemetry/${repo}).`, + }; +} + +/** + * Title and body of the release PR. + * + * @param {string} repo @param {string} version + * @returns {{ title: string, body: string }} + */ +function releasePrText(repo, version) { + return { + title: `Update ${repo} version to ${version}`, + body: `Update ${repo} version to \`${version}\`.\n\nSee https://github.com/open-telemetry/${repo}/releases/tag/${version}.`, + }; +} + +/** + * Placeholder that {@link maskVersions} substitutes for version numbers. + * Templates instantiated with it compare equal to any-version instances. + */ +const VERSION_MASK = 'vX.Y.Z'; + +/** @param {string} s @returns {string} */ +function maskVersions(s) { + return s.replace(/\bv\d+\.\d+\.\d+\b/g, VERSION_MASK); +} + +/** @param {string} title @param {string} repo @returns {boolean} */ +function isAutomationTitle(title, repo) { + return isAutomationText(title, [ + devPrText(repo, VERSION_MASK).title, + releasePrText(repo, VERSION_MASK).title, + ]); +} + +/** @param {string} body @param {string} repo @returns {boolean} */ +function isAutomationBody(body, repo) { + return isAutomationText(body, [ + devPrText(repo, VERSION_MASK).body, + releasePrText(repo, VERSION_MASK).body, + ]); +} + +/** + * True iff `text` was written by the automation: equal, after normalization + * and version masking, to one of `templates` (instantiated with + * {@link VERSION_MASK}). Any other difference means a maintainer edited the + * text, making it theirs. + * + * @param {string|undefined} text + * @param {string[]} templates + * @returns {boolean} + */ +function isAutomationText(text, templates) { + const masked = maskVersions(normalize(text)); + return templates.some((t) => masked === t); +} + +/** + * Normalize line endings and outer whitespace before comparing PR text. + * + * @param {string|undefined} text + * @returns {string} + */ +function normalize(text) { + return (text ?? '').replace(/\r\n/g, '\n').trim(); +} + /** * Log a created PR's URL (`gh pr create` prints it on stdout). * diff --git a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs index e14b63d2e909..bc53a5601943 100644 --- a/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs +++ b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs @@ -51,12 +51,31 @@ const INPUT = { const NO_PR = { 'pr list': { stdout: '[]', status: 0 } }; const PR_URL = 'https://github.com/open-telemetry/opentelemetry.io/pull/12345'; -const OPEN_DRAFT_PR = { - 'pr list': { stdout: '[{"number":10526,"isDraft":true}]', status: 0 }, -}; -const OPEN_READY_PR = { - 'pr list': { stdout: '[{"number":10526,"isDraft":false}]', status: 0 }, -}; + +// The titles and bodies the automation writes, as the live PRs carry them. +const DRAFT_TITLE = + 'DRAFT Update opentelemetry-specification to unreleased v1.59.0-dev'; +const DEV_BODY = + 'This is a draft PR used for identifying issues integrating the latest (unreleased) [opentelemetry-specification](https://github.com/open-telemetry/opentelemetry-specification).'; +const RELEASE_TITLE = 'Update opentelemetry-specification version to v1.59.0'; +const RELEASE_BODY = + 'Update opentelemetry-specification version to `v1.59.0`.\n\nSee https://github.com/open-telemetry/opentelemetry-specification/releases/tag/v1.59.0.'; + +/** Canned `gh pr list` response with a single open PR. */ +function openPr(fields) { + return { + 'pr list': { + stdout: JSON.stringify([{ number: 10526, ...fields }]), + status: 0, + }, + }; +} + +const OPEN_DRAFT_PR = openPr({ + isDraft: true, + title: DRAFT_TITLE, + body: DEV_BODY, +}); describe('create-or-finalize-pr: dev mode', () => { test('PR already open: no-op', () => { @@ -96,14 +115,8 @@ describe('create-or-finalize-pr: dev mode', () => { const create = findCall(gh.calls, 'pr', 'create'); assert.ok(create, 'gh pr create is called'); assert.ok(create.includes('--draft'), 'PR is created as draft'); - assert.equal( - flagValue(create, '--title'), - 'DRAFT Update opentelemetry-specification to unreleased v1.59.0-dev', - ); - assert.match( - flagValue(create, '--body'), - /draft PR used for identifying issues integrating the latest \(unreleased\) \[opentelemetry-specification\]\(https:\/\/github\.com\/open-telemetry\/opentelemetry-specification\)/, - ); + assert.equal(flagValue(create, '--title'), DRAFT_TITLE); + assert.equal(flagValue(create, '--body'), DEV_BODY); assert.ok( !findCall(git.calls, 'commit'), 'a branch with commits skips the bootstrap commit', @@ -144,8 +157,6 @@ describe('create-or-finalize-pr: dev mode', () => { }); describe('create-or-finalize-pr: release mode', () => { - const RELEASE_TITLE = 'Update opentelemetry-specification version to v1.59.0'; - test('no PR: creates non-draft release PR', () => { const gh = makeFakeRunner({ ...NO_PR, @@ -168,16 +179,11 @@ describe('create-or-finalize-pr: release mode', () => { assert.ok(create, 'gh pr create is called'); assert.ok(!create.includes('--draft'), 'release PR is not a draft'); assert.equal(flagValue(create, '--title'), RELEASE_TITLE); - const body = flagValue(create, '--body'); - assert.match(body, /`v1\.59\.0`/); - assert.match( - body, - /https:\/\/github\.com\/open-telemetry\/opentelemetry-specification\/releases\/tag\/v1\.59\.0/, - ); + assert.equal(flagValue(create, '--body'), RELEASE_BODY); assert.ok(logs.includes(PR_URL), 'the created PR URL is logged'); }); - test('open draft PR: one-time finalization (title + body, then ready)', () => { + test('draft PR in dev form: finalized — title + body, then ready', () => { const gh = makeFakeRunner(OPEN_DRAFT_PR); const git = makeFakeRunner(); const result = createOrFinalizePullRequest({ @@ -189,13 +195,19 @@ describe('create-or-finalize-pr: release mode', () => { log: noLog, }); assert.equal(result, 'updated'); - const ready = findCall(gh.calls, 'pr', 'ready'); - assert.ok(ready, 'gh pr ready is called'); - assert.ok(ready.includes(INPUT.branch), 'pr ready targets the branch'); + const list = findCall(gh.calls, 'pr', 'list'); + assert.equal( + flagValue(list, '--json'), + 'number,isDraft,title,body', + 'the PR query includes the fields the fixups inspect', + ); const edit = findCall(gh.calls, 'pr', 'edit'); assert.ok(edit, 'gh pr edit is called'); assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); - assert.ok(edit.includes('--body'), 'finalization writes the body once'); + assert.equal(flagValue(edit, '--body'), RELEASE_BODY); + const ready = findCall(gh.calls, 'pr', 'ready'); + assert.ok(ready, 'gh pr ready is called'); + assert.ok(ready.includes(INPUT.branch), 'pr ready targets the branch'); assert.ok( callIndex(gh.calls, 'pr', 'edit') < callIndex(gh.calls, 'pr', 'ready'), 'title and body are written while the PR is still a draft', @@ -203,8 +215,14 @@ describe('create-or-finalize-pr: release mode', () => { assert.equal(git.calls.length, 0, 'git is unused'); }); - test('open ready PR: re-syncs the title only', () => { - const gh = makeFakeRunner(OPEN_READY_PR); + test('draft PR with a maintainer-edited body: body is preserved', () => { + const gh = makeFakeRunner( + openPr({ + isDraft: true, + title: DRAFT_TITLE, + body: 'Hold this until the FAQ is updated. — maintainer', + }), + ); const git = makeFakeRunner(); const result = createOrFinalizePullRequest({ ...INPUT, @@ -215,26 +233,91 @@ describe('create-or-finalize-pr: release mode', () => { log: noLog, }); assert.equal(result, 'updated'); + const edit = findCall(gh.calls, 'pr', 'edit'); + assert.ok(edit, 'gh pr edit is called'); + assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); assert.ok( - !findCall(gh.calls, 'pr', 'ready'), - 'an already-ready PR skips pr ready', + !edit.includes('--body'), + 'the maintainer-owned body is preserved', ); + assert.ok(findCall(gh.calls, 'pr', 'ready'), 'gh pr ready is called'); + }); + + test('ready PR still in dev form: title and body are healed', () => { + // E.g. a maintainer marked the draft integration PR ready mid-cycle. + const gh = makeFakeRunner( + openPr({ isDraft: false, title: DRAFT_TITLE, body: DEV_BODY }), + ); + const git = makeFakeRunner(); + const result = createOrFinalizePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result, 'updated'); const edit = findCall(gh.calls, 'pr', 'edit'); assert.ok(edit, 'gh pr edit is called'); assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); + assert.equal(flagValue(edit, '--body'), RELEASE_BODY); assert.ok( - !edit.includes('--body'), - 'body is left alone to preserve maintainer notes', + !findCall(gh.calls, 'pr', 'ready'), + 'an already-ready PR skips pr ready', ); }); - test('open ready PR with matching title: full no-op', () => { - const gh = makeFakeRunner({ - 'pr list': { - stdout: `[{"number":10526,"isDraft":false,"title":${JSON.stringify(RELEASE_TITLE)}}]`, - status: 0, - }, + test('ready PR in the form of an older release: title and body re-synced', () => { + // A newer release landed while the release PR awaited merge. + const gh = makeFakeRunner( + openPr({ + isDraft: false, + title: RELEASE_TITLE.replaceAll('v1.59.0', 'v1.58.0'), + body: RELEASE_BODY.replaceAll('v1.59.0', 'v1.58.0'), + }), + ); + const git = makeFakeRunner(); + const result = createOrFinalizePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, }); + assert.equal(result, 'updated'); + const edit = findCall(gh.calls, 'pr', 'edit'); + assert.ok(edit, 'gh pr edit is called'); + assert.equal(flagValue(edit, '--title'), RELEASE_TITLE); + assert.equal(flagValue(edit, '--body'), RELEASE_BODY); + }); + + test('ready PR with maintainer-edited title and body: full no-op', () => { + const gh = makeFakeRunner( + openPr({ + isDraft: false, + title: 'Update the spec to v1.59.0 (hold for FAQ)', + body: 'Blocked on #12346.', + }), + ); + const git = makeFakeRunner(); + const result = createOrFinalizePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result, 'unchanged'); + assert.equal(gh.calls.length, 1, 'only the pr-list call runs'); + }); + + test('ready PR already in release form: full no-op', () => { + const gh = makeFakeRunner( + openPr({ isDraft: false, title: RELEASE_TITLE, body: RELEASE_BODY }), + ); const git = makeFakeRunner(); const result = createOrFinalizePullRequest({ ...INPUT, From 619d328579c19a2e1eeaa94a14db966e7d0d84b4 Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 10:58:30 -0400 Subject: [PATCH 13/14] Validate the latest-release tag before it reaches GITHUB_ENV pick-branch wrote the tag returned by `gh release view` to $GITHUB_ENV unvalidated in release mode, and workflow shell steps interpolate it (e.g. `git reset --hard $VERSION`). Git ref syntax rules out word splitting, but a hostile tag crafted as a command option (leading `--...`) could still inject arguments. Reject anything that isn't a plain vX.Y.Z as soon as the tag enters computeIntegrationVersion(). Addresses a security-review finding on the release-flow PR. --- scripts/gh/specs/pick-branch/index.mjs | 8 ++++++++ scripts/gh/specs/pick-branch/version.test.mjs | 18 ++++++++++++++++++ static/refcache.json | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/gh/specs/pick-branch/index.mjs b/scripts/gh/specs/pick-branch/index.mjs index cec49ede5360..9b2accf8eadb 100644 --- a/scripts/gh/specs/pick-branch/index.mjs +++ b/scripts/gh/specs/pick-branch/index.mjs @@ -63,6 +63,14 @@ export function computeIntegrationVersion({ }; const latestRelease = getLatestReleaseTag(); + // Reject malformed tags before latestRelease flows into $GITHUB_ENV, where + // workflow shell steps interpolate it (a tag crafted as a command option + // could otherwise reach, e.g., `git reset --hard $VERSION`). + if (!/^v\d+\.\d+\.\d+$/.test(latestRelease)) { + throw new Error( + `unexpected version: ${latestRelease} (expected vX.Y.Z as the latest release tag)`, + ); + } const pinned = parsePinnedVersion(pinnedVersion); let version = extractVersionFromBranches( diff --git a/scripts/gh/specs/pick-branch/version.test.mjs b/scripts/gh/specs/pick-branch/version.test.mjs index 7b295ec4a884..80fb2ecbbd54 100644 --- a/scripts/gh/specs/pick-branch/version.test.mjs +++ b/scripts/gh/specs/pick-branch/version.test.mjs @@ -291,6 +291,24 @@ describe('pick-branch: mode selection', () => { assert.equal(result.branch, `${PREFIX}-v1.59.0-dev`); }); + test('release mode: rejects malformed latest release tag', () => { + // The tag flows into $GITHUB_ENV and thence into workflow shell steps + // (e.g. `git reset --hard $VERSION`), so a tag crafted as an option + // (`--upload-pack=...`) must never leave this function. + assert.throws( + () => + computeIntegrationVersion({ + branchPrefix: PREFIX, + branchesOutput: ` origin/${PREFIX}-v1.59.0-dev\n`, + pinnedVersion: 'v1.58.0', + isReleased: isReleasedUnexpected, + getLatestReleaseTag: () => 'v99.0.0--upload-pack=/tmp/x', + log: noLog, + }), + /unexpected version/, + ); + }); + test('release mode: no branch -> create branch for latest release', () => { const result = computeIntegrationVersion({ branchPrefix: PREFIX, diff --git a/static/refcache.json b/static/refcache.json index 8f9672889392..b7e7434410ef 100644 --- a/static/refcache.json +++ b/static/refcache.json @@ -14681,7 +14681,7 @@ }, "https://github.com/open-telemetry/opentelemetry.io/blob/main/scripts/gh/specs/create-or-finalize-pr.mjs": { "StatusCode": 206, - "LastSeen": "2026-07-09T13:00:00.000000000Z" + "LastSeen": "2026-07-09T13:00:00Z" }, "https://github.com/open-telemetry/opentelemetry.io/branches/all?query=semconv-integration": { "StatusCode": 206, From 6b4b32f74b3ed1f79b8b73a579665fa5e22b9afb Mon Sep 17 00:00:00 2001 From: Patrice Chalin Date: Thu, 9 Jul 2026 11:00:46 -0400 Subject: [PATCH 14/14] Quote VERSION and BRANCH in workflow shell steps Defense-in-depth on top of the vX.Y.Z validation in pick-branch: the quoting keeps the steps hygienic should the validation ever be loosened (e.g. to admit pre-release tags). --- .../workflows/update-semconv-integration-branch.yml | 12 ++++++------ .github/workflows/update-spec-integration-branch.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/update-semconv-integration-branch.yml b/.github/workflows/update-semconv-integration-branch.yml index e56f3080d5dd..4870a9f4109b 100644 --- a/.github/workflows/update-semconv-integration-branch.yml +++ b/.github/workflows/update-semconv-integration-branch.yml @@ -43,11 +43,11 @@ jobs: - name: Checkout or create branch run: | - if ! git ls-remote --exit-code --heads origin $BRANCH; then - git checkout -b $BRANCH origin/main - git push -u origin $BRANCH + if ! git ls-remote --exit-code --heads origin "$BRANCH"; then + git checkout -b "$BRANCH" origin/main + git push -u origin "$BRANCH" else - git checkout $BRANCH + git checkout "$BRANCH" fi - name: Use CLA approved github bot @@ -69,8 +69,8 @@ jobs: git submodule update --init content-modules/${{ env.REPO }} cd content-modules/${{ env.REPO }} - if git ls-remote --exit-code --tags origin $VERSION; then - git reset --hard $VERSION + if git ls-remote --exit-code --tags origin "$VERSION"; then + git reset --hard "$VERSION" tag_exists=true else git reset --hard origin/main diff --git a/.github/workflows/update-spec-integration-branch.yml b/.github/workflows/update-spec-integration-branch.yml index 62847c34bffb..a0f072cd0fff 100644 --- a/.github/workflows/update-spec-integration-branch.yml +++ b/.github/workflows/update-spec-integration-branch.yml @@ -42,11 +42,11 @@ jobs: - name: Checkout or create branch run: | - if ! git ls-remote --exit-code --heads origin $BRANCH; then - git checkout -b $BRANCH origin/main - git push -u origin $BRANCH + if ! git ls-remote --exit-code --heads origin "$BRANCH"; then + git checkout -b "$BRANCH" origin/main + git push -u origin "$BRANCH" else - git checkout $BRANCH + git checkout "$BRANCH" fi - name: Use CLA approved github bot @@ -68,8 +68,8 @@ jobs: git submodule update --init content-modules/${{ env.REPO }} cd content-modules/${{ env.REPO }} - if git ls-remote --exit-code --tags origin $VERSION; then - git reset --hard $VERSION + if git ls-remote --exit-code --tags origin "$VERSION"; then + git reset --hard "$VERSION" tag_exists=true else git reset --hard origin/main