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
12 changes: 7 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
31 changes: 27 additions & 4 deletions scripts/release_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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] = {
Expand Down
41 changes: 41 additions & 0 deletions scripts/release_contract_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
30 changes: 17 additions & 13 deletions scripts/stable_release_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
validate_immutable_release_governance,
validate_publication_environment,
validate_release_attestation,
validate_new_release_identity,
validate_release_identity,
validate_release_immutability,
)
Expand Down Expand Up @@ -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:
Expand All @@ -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)


# --------------------------------------------------------------------------
Expand Down
43 changes: 33 additions & 10 deletions scripts/stable_release_orchestrator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()])
Expand All @@ -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(
Expand Down Expand Up @@ -4283,15 +4306,15 @@ 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(
[
(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:
Expand Down
Loading