diff --git a/.github/workflows/update-semconv-integration-branch.yml b/.github/workflows/update-semconv-integration-branch.yml index eec936eec52a..4870a9f4109b 100644 --- a/.github/workflows/update-semconv-integration-branch.yml +++ b/.github/workflows/update-semconv-integration-branch.yml @@ -39,15 +39,15 @@ 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: | - 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 @@ -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: 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 105ca64b6b88..a0f072cd0fff 100644 --- a/.github/workflows/update-spec-integration-branch.yml +++ b/.github/workflows/update-spec-integration-branch.yml @@ -38,15 +38,15 @@ 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: | - 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 @@ -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: scripts/gh/specs/create-or-finalize-pr.mjs --spec otel report-failure: needs: update-integration-branch diff --git a/content/en/site/build/ci-workflows.md b/content/en/site/build/ci-workflows.md index 758a8a3dfcc7..46f2c5971f39 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,31 @@ 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` 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. [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 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 + ### 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 +431,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 +462,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/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 diff --git a/scripts/gh/specs/create-or-finalize-pr.mjs b/scripts/gh/specs/create-or-finalize-pr.mjs new file mode 100755 index 000000000000..eb5f1d6c8ba8 --- /dev/null +++ b/scripts/gh/specs/create-or-finalize-pr.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// Create or finalize the integration-branch PR of a spec workflow, according +// 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 { + createOrFinalizePullRequest, + readEnvInputs, +} from './create-or-finalize-pr/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 outcome = createOrFinalizePullRequest({ + mode, + repo, + version, + branch, + dryRun, + runGh: (args) => run('gh', args), + runGit: (args) => run('git', args), + }); + + console.log(`[done] outcome: ${outcome}${dryRun ? ' (dry-run)' : ''}`); +} + +/** + * 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); +} + +/** 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 new file mode 100644 index 000000000000..6a0907f41e88 --- /dev/null +++ b/scripts/gh/specs/create-or-finalize-pr/index.mjs @@ -0,0 +1,359 @@ +// 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 +// the command file, ../create-or-finalize-pr.mjs. + +/** + * Result of an injected `gh`/`git` invocation. + * + * @typedef {Object} RunResult + * @property {string} stdout + * @property {number} status Exit code (0 on success). + */ + +/** + * 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 — 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. + */ + +/** + * 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 + * no commits over main (`gh pr create` fails otherwise). + * - **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 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]`. + * + * @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 {PrOutcome} + */ +export function createOrFinalizePullRequest({ + 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,body', + ]), + 'gh pr list', + ); + const pr = JSON.parse(list.stdout || '[]')[0] ?? null; + + return mode === 'dev' + ? 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 createDevPrIfMissing({ + pr, + repo, + version, + branch, + dryRun, + runGh, + runGit, + log, +}) { + if (pr) { + log(`PR #${pr.number} is already open for ${branch}; nothing to do.`); + return 'unchanged'; + } + + const revList = must( + runGit(['rev-list', 'origin/main..HEAD']), + 'git rev-list', + ); + const needsBootstrap = revList.stdout.trim() === ''; + + const { title, body } = devPrText(repo, version); + 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(`${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) { + const created = must( + runGh(['pr', 'create', '--title', title, '--body', body, '--draft']), + 'gh pr create', + ); + logUrl(created, log); + } + return 'created'; +} + +/** Release mode: create the release PR, or apply the fixups the open PR needs. */ +function createOrFinalizeReleasePr({ + pr, + repo, + version, + branch, + dryRun, + runGh, + log, +}) { + const { title, body } = releasePrText(repo, version); + const prefix = dryRun ? '[dry-run] ' : ''; + + if (!pr) { + log(`${prefix}Creating release PR "${title}".`); + if (!dryRun) { + const created = must( + runGh(['pr', 'create', '--title', title, '--body', body]), + 'gh pr create', + ); + logUrl(created, log); + } + return 'created'; + } + + // 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 (!editArgs.length && !pr.isDraft) { + log(`PR #${pr.number} is already finalized; nothing to do.`); + return 'unchanged'; + } + + 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) { + // 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). + * + * @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`. + * @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 }; +} diff --git a/scripts/gh/specs/create-or-finalize-pr/inputs.test.mjs b/scripts/gh/specs/create-or-finalize-pr/inputs.test.mjs new file mode 100644 index 000000000000..4f76b8b2ae48 --- /dev/null +++ b/scripts/gh/specs/create-or-finalize-pr/inputs.test.mjs @@ -0,0 +1,95 @@ +// Tests for readEnvInputs: strict validation of the MODE/VERSION/BRANCH +// environment values that the create-or-finalize-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('create-or-finalize-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/create-or-finalize-pr/pr.test.mjs b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs new file mode 100644 index 000000000000..bc53a5601943 --- /dev/null +++ b/scripts/gh/specs/create-or-finalize-pr/pr.test.mjs @@ -0,0 +1,500 @@ +// 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 { createOrFinalizePullRequest } 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 }`. 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 = {}, 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 }; + }; + 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)); +} + +/** 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]; +} + +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 PR_URL = 'https://github.com/open-telemetry/opentelemetry.io/pull/12345'; + +// 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', () => { + const gh = makeFakeRunner(OPEN_DRAFT_PR); + const git = makeFakeRunner(); + const result = createOrFinalizePullRequest({ + ...INPUT, + mode: 'dev', + 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'); + assert.equal(git.calls.length, 0, 'git is unused'); + }); + + test('no PR, branch has commits: creates draft 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: (m) => logs.push(m), + }); + 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'); + 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', + ); + assert.ok(logs.includes(PR_URL), 'the created PR URL is logged'); + }); + + test('no PR, branch even with main: bootstraps with an empty commit', () => { + 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', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + 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'); + assert.equal( + flagValue(commit, '-m'), + `Trigger PR creation for ${INPUT.branch}`, + ); + assert.ok(findCall(git.calls, 'push'), 'bootstrap commit is pushed'); + assert.ok( + callIndex(seq, 'git', 'push') < callIndex(seq, 'gh', 'pr', 'create'), + 'bootstrap commit is pushed before gh pr create runs', + ); + }); +}); + +describe('create-or-finalize-pr: release mode', () => { + test('no PR: creates non-draft release 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: (m) => logs.push(m), + }); + 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'); + assert.equal(flagValue(create, '--title'), RELEASE_TITLE); + assert.equal(flagValue(create, '--body'), RELEASE_BODY); + assert.ok(logs.includes(PR_URL), 'the created PR URL is logged'); + }); + + test('draft PR in dev form: finalized — title + body, then ready', () => { + const gh = makeFakeRunner(OPEN_DRAFT_PR); + const git = makeFakeRunner(); + const result = createOrFinalizePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result, 'updated'); + 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.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', + ); + assert.equal(git.calls.length, 0, 'git is unused'); + }); + + 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, + 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.ok( + !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( + !findCall(gh.calls, 'pr', 'ready'), + 'an already-ready PR skips pr ready', + ); + }); + + 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, + 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'); + }); +}); + +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 = createOrFinalizePullRequest({ + ...INPUT, + mode: 'dev', + dryRun: true, + runGh: gh.run, + runGit: git.run, + log: (m) => logs.push(m), + }); + 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'), + 'dry-run keeps git read-only', + ); + 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 = createOrFinalizePullRequest({ + ...INPUT, + mode: 'release', + dryRun: true, + runGh: gh.run, + runGit: git.run, + log: noLog, + }); + assert.equal(result, 'updated', 'outcome a write run would produce'); + assert.equal(gh.calls.length, 1, 'only the read-only pr-list call runs'); + }); +}); + +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( + () => + createOrFinalizePullRequest({ + ...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( + () => + createOrFinalizePullRequest({ + ...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( + () => + createOrFinalizePullRequest({ + ...INPUT, + mode: 'release', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /gh pr ready failed/, + ); + }); + + 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({ + 'rev-list origin/main..HEAD': { stdout: '', status: 0 }, + 'commit --allow-empty': { stdout: '', status: 1 }, + }); + assert.throws( + () => + createOrFinalizePullRequest({ + ...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( + () => + createOrFinalizePullRequest({ + ...INPUT, + mode: 'prod', + dryRun: false, + runGh: gh.run, + runGit: git.run, + log: noLog, + }), + /unexpected mode: prod/, + ); + assert.equal(gh.calls.length, 0, 'invalid mode fails before any call'); + }); +}); diff --git a/scripts/gh/specs/pick-branch/cli.mjs b/scripts/gh/specs/pick-branch/cli.mjs index 5fad0d2e59a9..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 @@ -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..9b2accf8eadb 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,15 @@ 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( branchesOutput, @@ -63,6 +79,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 +108,7 @@ export function computeIntegrationVersion({ } return { + mode: 'dev', version, branch: `${branchPrefix}-${version}-dev`, warnings, @@ -126,6 +159,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 +198,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,15 +409,17 @@ 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]`, + ` [--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.', diff --git a/scripts/gh/specs/pick-branch/version.test.mjs b/scripts/gh/specs/pick-branch/version.test.mjs index b5daad44c85e..80fb2ecbbd54 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,171 @@ 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: 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, + 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/, + ); + }); +}); diff --git a/static/refcache.json b/static/refcache.json index 03c6496007da..b7e7434410ef 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:00Z" + }, "https://github.com/open-telemetry/opentelemetry.io/branches/all?query=semconv-integration": { "StatusCode": 206, "LastSeen": "2026-07-05T06:52:17.295664434Z"