diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74d8e5c..0869653 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -374,11 +374,13 @@ default-branch schedule events remain automatic; `workflow_run` continuations also require a successful stage run on the default branch with owner actor and triggering actor before either job starts. -GitHub artifact tags are `vMAJOR.MINOR.PATCH`, `vMAJOR.MINOR.PATCH-N`, `bNNNN`, -or `bNNNN-N`. Historical `bNNNN-llamadart.N` and prior wrapper forms are -read-only compatibility inputs. Any future npm package must use an independently -monotonic version with stable/nightly dist-tags because npm treats -`vMAJOR.MINOR.PATCH-N` as a prerelease. +New bridge asset tags are npm-shaped `vMAJOR.MINOR.PATCH` with rebuild `0`: +`select_next_release_target` skips a published or claimed version by taking +the next free patch version, and `release_contract.py validate-release` rejects +a new `-N` tag in the candidate and publish workflows, because npm orders +`vMAJOR.MINOR.PATCH-N` as a prerelease. Earlier `-N` bridge tags, the native +forms `vMAJOR.MINOR.PATCH-N`, `bNNNN` and `bNNNN-N`, historical +`bNNNN-llamadart.N` and prior wrapper forms are read-only compatibility inputs. Never interpolate `${{ inputs.* }}` directly inside a workflow `run` script. Transport dispatch inputs through `env` and use quoted shell expansions. diff --git a/README.md b/README.md index e0ad7c1..80e3860 100644 --- a/README.md +++ b/README.md @@ -456,18 +456,19 @@ publication workflow run using the same `candidate_run_id` and `qualification_run_id`; never rerun the old run. A different candidate must never overwrite an earlier one under the same output tag. -GitHub artifact tags follow the shared convention: - -- stable: `vMAJOR.MINOR.PATCH` -- stable rebuild: `vMAJOR.MINOR.PATCH-N` -- development: `bNNNN` -- development rebuild: `bNNNN-N` - -Historical `bNNNN-llamadart.N` and earlier wrapper forms are accepted only when -reading existing manifests and are never emitted. Any future npm package must -use an independently monotonic version sequence with stable/nightly dist-tags; -`vMAJOR.MINOR.PATCH-N` is prerelease-ordered by npm and must not be reused as the -npm version. +New bridge asset release tags are npm-shaped `vMAJOR.MINOR.PATCH` with +`release_rebuild` `0`. When the next version is already published or claimed by +an unfinished pipeline, the orchestrator takes the next free patch version; it +never appends `-N`, which npm orders as a prerelease below the plain version. +A new tag is never lower than a published tag or a live claim; a claim released +without publication stops counting, so its version may be taken again or stay +unused. `release_contract.py validate-release`, which the candidate and publish +workflows run, rejects a new tag with a rebuild suffix. + +Tags published before this rule, such as `v0.1.47-1`, and the shared native +forms (`vMAJOR.MINOR.PATCH-N`, `bNNNN`, `bNNNN-N`) stay readable. Historical +`bNNNN-llamadart.N` and earlier wrapper forms are accepted only when reading +existing manifests and are never emitted. After publish, assets are CDN-available at: diff --git a/scripts/release_contract.py b/scripts/release_contract.py index 4c9bffc..35de2f5 100644 --- a/scripts/release_contract.py +++ b/scripts/release_contract.py @@ -3,9 +3,12 @@ GitHub release tags are shared with the native release convention. Stable releases use ``vMAJOR.MINOR.PATCH`` and rebuilds append ``-N``. Development -releases use ``bNNNN`` and rebuilds append ``-N``. Historical -``*-llamadart.N`` wrappers remain readable so an existing manifest can be used -as an ordering boundary, but this module never emits them. +releases use ``bNNNN`` and rebuilds append ``-N``. Bridge asset releases keep +npm-compatible ordering, so a new bridge asset release never carries ``-N`` +(:func:`validate_new_release_identity`); suffixed bridge tags published before +that rule stay readable. Historical ``*-llamadart.N`` wrappers remain readable +so an existing manifest can be used as an ordering boundary, but this module +never emits them. """ from __future__ import annotations @@ -305,6 +308,26 @@ def validate_release_identity(release_tag: str, rebuild: int, upstream_tag: str) return release +def validate_new_release_identity( + release_tag: str, rebuild: int, upstream_tag: str +) -> ReleaseVersion: + """Validate a bridge asset release that is about to be built or published. + + Bridge asset tags keep npm-compatible ordering, so a new release is always + an unsuffixed tag with rebuild 0. npm orders ``vMAJOR.MINOR.PATCH-N`` as a + prerelease before ``vMAJOR.MINOR.PATCH``, which would rank newer assets + below older ones. Suffixed tags published before this rule, such as + ``v0.1.47-1``, stay readable through :func:`validate_release_identity`. + """ + release = validate_release_identity(release_tag, rebuild, upstream_tag) + if release.rebuild != 0: + raise ContractError( + f"new bridge asset release tag {release_tag!r} must not carry a rebuild " + "suffix; take the next free unsuffixed version instead" + ) + return release + + def _require_native_upstream_identity( native_version: ReleaseVersion, upstream: UpstreamVersion ) -> None: @@ -1267,7 +1290,7 @@ def main() -> int: args = _parser().parse_args() try: if args.command == "validate-release": - release = validate_release_identity( + release = validate_new_release_identity( args.release_tag, args.release_rebuild, args.upstream_tag ) result: dict[str, Any] = { diff --git a/scripts/release_contract_test.py b/scripts/release_contract_test.py index e55913e..d92192b 100644 --- a/scripts/release_contract_test.py +++ b/scripts/release_contract_test.py @@ -35,6 +35,7 @@ validate_immutable_release_governance, validate_publication_environment, validate_native_identity, + validate_new_release_identity, validate_release_attestation, validate_release_identity, validate_release_immutability, @@ -468,6 +469,46 @@ def test_release_identity_keeps_strict_syntax_and_rebuild(self) -> None: with self.subTest(case=label), self.assertRaises(ContractError): validate_release_identity(release, rebuild, upstream) + def test_new_bridge_release_never_carries_a_rebuild_suffix(self) -> None: + """npm orders v0.1.50-1 before v0.1.50, so new assets stay unsuffixed.""" + self.assertEqual( + validate_new_release_identity("v0.1.50", 0, "v0.5.0").tag, "v0.1.50" + ) + for release, rebuild, upstream in ( + ("v0.1.50-1", 1, "v0.5.0"), + ("v0.1.47-1", 1, "v0.4.1"), + ("b10600-1", 1, "b10600"), + ): + with self.subTest(release=release), self.assertRaises(ContractError): + validate_new_release_identity(release, rebuild, upstream) + # Suffixed tags published before the rule stay readable. + self.assertEqual( + validate_release_identity("v0.1.47-1", 1, "v0.4.1").rebuild, 1 + ) + + def test_validate_release_cli_rejects_a_new_rebuild_suffix(self) -> None: + """bridge_candidate.yml and publish_assets.yml gate on this command.""" + command = [ + sys.executable, + str(Path(__file__).with_name("release_contract.py")), + "validate-release", + "--upstream-tag", + "v0.5.0", + ] + accepted = subprocess.run( + [*command, "--release-tag", "v0.1.50", "--release-rebuild", "0"], + capture_output=True, + text=True, + ) + self.assertEqual(accepted.returncode, 0, accepted.stderr) + rejected = subprocess.run( + [*command, "--release-tag", "v0.1.50-1", "--release-rebuild", "1"], + capture_output=True, + text=True, + ) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("must not carry a rebuild suffix", rejected.stderr + rejected.stdout) + def test_native_identity_still_encodes_its_upstream(self) -> None: self.assertEqual( validate_native_identity("v0.2.0-1", 1, "v0.2.0").tag, "v0.2.0-1" diff --git a/scripts/stable_release_orchestrator.py b/scripts/stable_release_orchestrator.py index 605003c..1a6aa98 100644 --- a/scripts/stable_release_orchestrator.py +++ b/scripts/stable_release_orchestrator.py @@ -57,6 +57,7 @@ validate_immutable_release_governance, validate_publication_environment, validate_release_attestation, + validate_new_release_identity, validate_release_identity, validate_release_immutability, ) @@ -1088,16 +1089,20 @@ def select_next_release_target( ) -> ReleaseTarget: """Pick the next free bridge-asset tag, independently of the upstream tag. - ``release_tags`` are the assets repository's existing releases and set the - version floor. ``taken`` are tags already claimed by an unfinished pipeline; - they never advance the version but must not be collided with, so a claimed - tag falls through to the next free rebuild of the same version. + ``release_tags`` are the assets repository's existing releases. ``taken`` + are tags still claimed by a pipeline. Both set the version floor, so a new + tag is never lower than a published tag or a live claim. A claim that is + released without publication stops counting, so its version may be taken + again or stay unused. The result is always an unsuffixed + ``vMAJOR.MINOR.PATCH`` with rebuild 0: npm orders ``-N`` as a prerelease of + the same version, so a collision moves to the next free patch version + instead of a rebuild suffix. """ parse_upstream_tag(upstream_tag) published = {tag for tag in release_tags if isinstance(tag, str)} claimed = published | {tag for tag in taken if isinstance(tag, str)} versions = [] - for tag in published: + for tag in claimed: try: version = parse_release_tag(tag, allow_legacy=True) except ContractError: @@ -1107,17 +1112,16 @@ def select_next_release_target( if versions: highest = max(versions, key=lambda value: (*value.version_parts, value.rebuild)) major, minor, patch = highest.version_parts - base_tag = f"v{major}.{minor}.{patch + 1}" + patch += 1 else: - base_tag = INITIAL_STABLE_RELEASE_TAG + major, minor, patch = parse_release_tag(INITIAL_STABLE_RELEASE_TAG).version_parts - rebuild = 0 - tag = base_tag + tag = f"v{major}.{minor}.{patch}" while tag in claimed: - rebuild += 1 - tag = f"{base_tag}-{rebuild}" - validate_release_identity(tag, rebuild, upstream_tag) - return ReleaseTarget(tag, rebuild) + patch += 1 + tag = f"v{major}.{minor}.{patch}" + validate_new_release_identity(tag, 0, upstream_tag) + return ReleaseTarget(tag, 0) # -------------------------------------------------------------------------- diff --git a/scripts/stable_release_orchestrator_test.py b/scripts/stable_release_orchestrator_test.py index ef105b4..70107f1 100644 --- a/scripts/stable_release_orchestrator_test.py +++ b/scripts/stable_release_orchestrator_test.py @@ -1620,12 +1620,35 @@ def test_seed_release_when_no_stable_assets_exist(self) -> None: self.assertEqual(target.release_tag, sro.INITIAL_STABLE_RELEASE_TAG) self.assertEqual(target.release_rebuild, 0) - def test_rebuild_suffix_when_next_version_tag_is_taken(self) -> None: + def test_taken_next_version_moves_to_the_next_patch_version(self) -> None: target = sro.select_next_release_target( ["v0.1.39", "v0.1.40"], upstream_tag="v0.2.0", taken={"v0.1.41"} ) - self.assertEqual(target.release_tag, "v0.1.41-1") - self.assertEqual(target.release_rebuild, 1) + self.assertEqual(target.release_tag, "v0.1.42") + self.assertEqual(target.release_rebuild, 0) + + def test_claims_set_the_floor_so_tags_follow_dispatch_order(self) -> None: + # v0.1.50 was claimed and released unpublished; v0.1.51 is still in + # flight. A later pipeline must not take v0.1.50 below it. + target = sro.select_next_release_target( + ["v0.1.49"], upstream_tag="v0.5.0", taken={"v0.1.51"} + ) + self.assertEqual(target.release_tag, "v0.1.52") + self.assertEqual(target.release_rebuild, 0) + + def test_historical_rebuild_tag_sets_the_floor_but_is_never_emitted(self) -> None: + target = sro.select_next_release_target( + ["v0.1.47", "v0.1.47-1"], upstream_tag="v0.4.1", taken={"v0.1.48"} + ) + self.assertEqual(target.release_tag, "v0.1.49") + self.assertEqual(target.release_rebuild, 0) + + def test_seed_release_collision_moves_to_the_next_patch_version(self) -> None: + target = sro.select_next_release_target( + [], upstream_tag="v0.2.0", taken={sro.INITIAL_STABLE_RELEASE_TAG} + ) + self.assertEqual(target.release_tag, "v0.1.1") + self.assertEqual(target.release_rebuild, 0) def test_run_history_includes_claims_since_the_last_asset_publication(self) -> None: prior = asset_release_stub() @@ -2734,8 +2757,8 @@ def test_in_flight_pipeline_of_another_build_still_reserves_its_tag(self) -> Non qualification_status="in_progress" ) inputs = gateway.dispatches[0]["inputs"] - self.assertEqual(inputs["release_tag"], "v0.1.40-1") - self.assertEqual(inputs["release_rebuild"], "1") + self.assertEqual(inputs["release_tag"], "v0.1.41") + self.assertEqual(inputs["release_rebuild"], "0") def test_later_qualified_provenance_waits_for_earlier_immutable_publication( self, @@ -3413,7 +3436,7 @@ def test_backlog_dry_run_reserves_an_earlier_planned_output_tag(self) -> None: dry_run=True, reserved_release_tags={"v0.1.40"}, ) - self.assertEqual(plan.release_target.release_tag, "v0.1.40-1") + self.assertEqual(plan.release_target.release_tag, "v0.1.41") def test_orphan_assets_tag_ref_is_reserved_before_candidate_dispatch(self) -> None: routes = self._routes(releases=[asset_release_stub()]) @@ -3434,8 +3457,8 @@ def test_orphan_assets_tag_ref_is_reserved_before_candidate_dispatch(self) -> No workspace=self.tmp, dry_run=True, ) - self.assertEqual(plan.release_target.release_tag, "v0.1.40-1") - self.assertEqual(plan.release_target.release_rebuild, 1) + self.assertEqual(plan.release_target.release_tag, "v0.1.41") + self.assertEqual(plan.release_target.release_rebuild, 0) def test_candidate_dispatch_inputs_carry_the_live_governance_proof(self) -> None: dispatched = run_payload( @@ -4283,7 +4306,7 @@ def test_identical_candidate_with_publication_in_flight_keeps_its_tag( ], [ ("v0.2.0", "in_flight", "v0.1.42"), - ("v0.2.1", "dispatch_candidate", "v0.1.42-1"), + ("v0.2.1", "dispatch_candidate", "v0.1.43"), ], ) self.assertEqual( @@ -4291,7 +4314,7 @@ def test_identical_candidate_with_publication_in_flight_keeps_its_tag( (record["workflow_file"], record["inputs"]["release_tag"], record["inputs"]["release_rebuild"]) for record in gateway.dispatches ], - [(sro.CANDIDATE_WORKFLOW_FILE, "v0.1.42-1", "1")], + [(sro.CANDIDATE_WORKFLOW_FILE, "v0.1.43", "0")], ) def test_differing_candidate_is_qualified_not_skipped(self) -> None: