diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b6a8acbf..daf2fca6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,12 +165,23 @@ jobs: uses: ./.github/actions/setup-rust with: cache: "false" + - name: Derive verified release scope + id: release_scope + timeout-minutes: 2 + run: | + bash tools/release/release-please-state.sh "$PWD" HEAD bash tools/release/with-release-history.sh "$PWD" "$RELEASE_HEAD_SHA" tools/dev/bun.sh tools/release/verify-publication-candidate.mts \ + --derive-products \ + --head-ref "$RELEASE_HEAD_SHA" \ + --github-output "$GITHUB_OUTPUT" - name: Plan product releases id: release_plan + env: + PRODUCTS_JSON: ${{ steps.release_scope.outputs.products_json }} run: | release_plan_args=( --from-product-tags --include-current-tags + --products-json "$PRODUCTS_JSON" --head-ref "$RELEASE_HEAD_SHA" --format github-output ) diff --git a/src/docs/maintainers/release.md b/src/docs/maintainers/release.md index b733be98c..ca256ea59 100644 --- a/src/docs/maintainers/release.md +++ b/src/docs/maintainers/release.md @@ -241,6 +241,12 @@ does not use GitHub Search or the Issues API's eventually consistent label index. A pending merged PR means publication is unfinished; it is never reported as “no releasable changes.” +Publication derives its product scope from the latest verified Release Please +release commit before checking product tags. Later changes to products outside +that candidate do not block the pending release or add products to it. Selected +products still require valid version transitions and exact dependency pins; +unscoped tag planning continues to reject unversioned release-affecting changes. + ## Qualification contract Root publication admission accepts only a current-main candidate with one non-cancelled CI run whose `head_sha` is exact and whose `Qualified` gate succeeded. That record covers required checks, tests, builds, policy, selected E2E, and named build artifacts. A successful `Builds` job alone is insufficient. After the root job pins the immutable release transport tag, the rest of that run remains bound to the exact transaction without re-evaluating the moving main branch. diff --git a/tools/release/release-graph.mts b/tools/release/release-graph.mts index 9cabfaa15..d1fdf767e 100644 --- a/tools/release/release-graph.mts +++ b/tools/release/release-graph.mts @@ -1488,9 +1488,26 @@ export function buildPlan(graph, files, prefix = 'release-graph') { export function buildPlanFromProductTags( graph, headRef, - { includeCurrentTags = false, prefix = 'release-graph', root = ROOT } = {}, + { + includeCurrentTags = false, + selectedProducts = Object.keys(graph.products), + prefix = 'release-graph', + root = ROOT, + } = {}, ) { const products = graph.products; + if ( + !Array.isArray(selectedProducts) || + selectedProducts.length === 0 || + new Set(selectedProducts).size !== selectedProducts.length || + selectedProducts.some( + (product) => typeof product !== 'string' || !Object.hasOwn(products, product), + ) + ) { + throw new Error( + `${prefix}: selected products must be a non-empty list of unique known products`, + ); + } const direct = new Set(); const changed = new Set(); const currentTaggedProducts = new Set(); @@ -1500,7 +1517,8 @@ export function buildPlanFromProductTags( root, }); - for (const [product, config] of Object.entries(products)) { + for (const product of selectedProducts) { + const config = products[product]; const baseRef = latestProductTag(config, headRef, prefix, root); const transition = productVersionTransitionStatus(product, config, baseRef, headRef, { includeCurrentTags, diff --git a/tools/release/release-version-transition.test.mts b/tools/release/release-version-transition.test.mts index 5093d26a9..e43465f1f 100644 --- a/tools/release/release-version-transition.test.mts +++ b/tools/release/release-version-transition.test.mts @@ -170,20 +170,41 @@ if (phase === 'write') { } writeFileSync(graphPath, JSON.stringify(releaseGraph)); } else if (phase === 'assert') { - const plan = (includeCurrentTags = false) => + const plan = (includeCurrentTags = false, selectedProducts = undefined) => buildPlanFromProductTags(releaseGraph, 'HEAD', { prefix: 'transition-test', root, includeCurrentTags, + selectedProducts, }); switch (scenario) { case 'compatible': { const result = plan(); assert.deepEqual(result.releaseProducts, [native, wasix]); assert.equal(result.changedFiles.includes('packages/vector/release.toml'), true); + assert.throws( + () => + buildPlanFromProductTags( + { + ...releaseGraph, + shared_release_sources: [ + { files: [`${PRODUCTS[native]}/VERSION`], products: [vector] }, + ], + }, + 'HEAD', + { root, selectedProducts: [vector] }, + ), + /manifest version remains 1[.]0[.]0/u, + 'shared inputs outside the publication scope still affect selected products', + ); break; } case 'inline-source': + assert.deepEqual(plan(false, [native, wasix]).releaseProducts, [native, wasix]); + assert.throws(() => plan(false, [vector]), /manifest version remains 1[.]0[.]0/u); + for (const invalid of [[], [native, native], ['unknown'], null]) { + assert.throws(() => plan(false, invalid), /unique known products/u); + } assert.throws( () => plan(), /oliphaunt-extension-vector has release-affecting changes .* manifest version remains 1[.]0[.]0.*packages\/vector\/release[.]toml/u, diff --git a/tools/release/release_plan.mts b/tools/release/release_plan.mts index fd84abb4e..b1ece50d5 100644 --- a/tools/release/release_plan.mts +++ b/tools/release/release_plan.mts @@ -85,6 +85,7 @@ function parseArgs(argv) { headRef: 'HEAD', fromProductTags: false, includeCurrentTags: false, + products: undefined, changedFiles: [], format: 'text', }; @@ -110,6 +111,12 @@ function parseArgs(argv) { args.fromProductTags = true; } else if (value === '--include-current-tags') { args.includeCurrentTags = true; + } else if (value === '--products-json') { + try { + args.products = JSON.parse(argv[++index]); + } catch { + fail('--products-json requires a JSON product list'); + } } else if (value === '--changed-file') { if (index + 1 >= argv.length) { fail('--changed-file requires a value'); @@ -128,7 +135,7 @@ function parseArgs(argv) { args.format = value.slice('--format='.length); } else if (value === '-h' || value === '--help') { console.log( - 'usage: bash tools/release/release-plan.sh [--base-ref REF] [--head-ref REF] [--from-product-tags] [--include-current-tags] [--changed-file PATH...] [--format text|json|github-output]', + 'usage: bash tools/release/release-plan.sh [--base-ref REF] [--head-ref REF] [--from-product-tags [--products-json JSON]] [--include-current-tags] [--changed-file PATH...] [--format text|json|github-output]', ); process.exit(0); } else { @@ -138,6 +145,12 @@ function parseArgs(argv) { if (!['text', 'json', 'github-output'].includes(args.format)) { fail('--format must be one of: text, json, github-output'); } + if ( + args.products !== undefined && + (!args.fromProductTags || args.baseRef || args.changedFiles.length) + ) { + fail('--products-json requires only --from-product-tags planning'); + } return args; } @@ -149,6 +162,7 @@ function planForArgs(args) { } else if (args.fromProductTags) { plan = buildPlanFromProductTags(graph, args.headRef, { includeCurrentTags: args.includeCurrentTags, + selectedProducts: args.products, prefix: TOOL, }); } else if (args.baseRef) { diff --git a/tools/release/validate-release-workflow-inputs.test.mts b/tools/release/validate-release-workflow-inputs.test.mts index e687d53d6..99246ac30 100644 --- a/tools/release/validate-release-workflow-inputs.test.mts +++ b/tools/release/validate-release-workflow-inputs.test.mts @@ -4,6 +4,20 @@ import path from 'node:path'; import test from 'node:test'; const ROOT = path.resolve(import.meta.dir, '../..'); +test('publication scopes tag planning to the verified Release Please candidate', () => { + const { steps } = Bun.YAML.parse( + readFileSync(path.join(ROOT, '.github/workflows/release.yml'), 'utf8'), + ).jobs['plan-candidate']; + const identity = steps.findIndex((step) => step.id === 'release_scope'); + const plan = steps.findIndex((step) => step.id === 'release_plan'); + assert.ok(identity >= 0 && identity < plan); + assert.match(steps[identity].run, /--derive-products/u); + assert.equal(steps[plan].env.PRODUCTS_JSON, `\${{ steps.release_scope.outputs.products_json }}`); + assert.match(steps[plan].run, /--products-json "\$PRODUCTS_JSON"/u); + const proof = steps.findIndex((step) => step.id === 'verify_publication_candidate'); + assert.ok(proof > plan); + assert.equal(steps[proof].env.PRODUCTS_JSON, `\${{ steps.release_plan.outputs.products_json }}`); +}); test('registry bootstrap remains eligible when qualification dispatch was skipped', () => { const { jobs } = Bun.YAML.parse( readFileSync(path.join(ROOT, '.github/workflows/release.yml'), 'utf8'), diff --git a/tools/release/verify-publication-candidate.mts b/tools/release/verify-publication-candidate.mts index f6605e420..6ca31df06 100644 --- a/tools/release/verify-publication-candidate.mts +++ b/tools/release/verify-publication-candidate.mts @@ -4,7 +4,6 @@ import { appendFileSync } from 'node:fs'; import { ROOT } from './release-graph.mts'; import { - deriveReleaseProducts, latestVerifiedReleaseCommit, releaseCommit, releaseCommitFile, @@ -47,7 +46,12 @@ function manifestVersions(repo, commit, products) { } export function derivePublicationProducts({ repo = ROOT, headRef = 'HEAD' } = {}) { - return deriveReleaseProducts({ repo, headRef: publicationCommit(repo, headRef) }).products; + const commit = publicationCommit(repo, headRef); + const verified = latestVerifiedReleaseCommit({ repo, headRef: commit }); + if (verified === null) { + throw error(`no verified release commit is reachable from publication commit ${commit}`); + } + return verified.products; } export function resolvePublicationPlanningSource({ repo = ROOT, headRef = 'HEAD' } = {}) { @@ -143,7 +147,7 @@ function parseArgs(argv) { throw error(`--products-json must be valid JSON: ${cause.message}`); } } - return { githubOutput, headRef, products, resolvePlanHead }; + return { githubOutput, headRef, products, resolvePlanHead, deriveProducts }; } if (import.meta.main) { @@ -165,12 +169,15 @@ if (import.meta.main) { `mode=${verified.mode}`, `publication_sha=${verified.publicationSha}`, `release_sha=${verified.releaseSha}`, + `products_json=${JSON.stringify(verified.products)}`, '', ].join('\n'), ); } console.log( - `verified publication commit ${verified.publicationSha} for ${verified.products.length} product(s)`, + args.deriveProducts + ? JSON.stringify(verified.products) + : `verified publication commit ${verified.publicationSha} for ${verified.products.length} product(s)`, ); } catch (cause) { console.error(cause instanceof Error ? cause.message : String(cause)); diff --git a/tools/release/verify-publication-candidate.test.mts b/tools/release/verify-publication-candidate.test.mts index d77930ffc..b9747574b 100644 --- a/tools/release/verify-publication-candidate.test.mts +++ b/tools/release/verify-publication-candidate.test.mts @@ -25,6 +25,7 @@ switch (scenario) { break; case 'controller': assert.notEqual(headRef, release); + assert.deepEqual(derivePublicationProducts(options), ['alpha']); assert.deepEqual(verifyPublicationCandidate(options), { mode: 'release-bump', publicationSha: headRef,