Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
6 changes: 6 additions & 0 deletions src/docs/maintainers/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 20 additions & 2 deletions tools/release/release-graph.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion tools/release/release-version-transition.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion tools/release/release_plan.mts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ function parseArgs(argv) {
headRef: 'HEAD',
fromProductTags: false,
includeCurrentTags: false,
products: undefined,
changedFiles: [],
format: 'text',
};
Expand All @@ -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');
Expand All @@ -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 {
Expand All @@ -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;
}

Expand All @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions tools/release/validate-release-workflow-inputs.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
15 changes: 11 additions & 4 deletions tools/release/verify-publication-candidate.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { appendFileSync } from 'node:fs';

import { ROOT } from './release-graph.mts';
import {
deriveReleaseProducts,
latestVerifiedReleaseCommit,
releaseCommit,
releaseCommitFile,
Expand Down Expand Up @@ -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' } = {}) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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));
Expand Down
1 change: 1 addition & 0 deletions tools/release/verify-publication-candidate.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading