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
20 changes: 9 additions & 11 deletions .github/DEPLOYMENT_CUTOVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,25 @@

`.deploy/workers.yaml` is the deployment catalog. The pinned compiler emits
`deployment-descriptor` snapshots whose package-manifest version is
informational; Release Control supplies the candidate and stable versions.
informational; Release Control supplies one exact target version and channel.

Only these Release Control-authorized entrypoints may mutate deployment state:

- `deploy-prepare.yml`
- `deploy-candidate-publish.yml`
- `deploy-stable-publish.yml`
- `deploy-image-alias.yml`
- `deploy-finalize.yml`
- `deploy-publish.yml`
- `deploy-verify.yml`

Prepare builds one independent job per build unit and uploads deterministic
artifacts. Every later phase downloads those artifacts and refuses identity or
digest drift. Candidate publication creates the immutable RC and moves
`@next`. Stable publication creates the immutable stable version and moves
`@next`; finalize moves `@latest`, preserving `@next >= @latest` across partial
failure. No publication phase compiles source.
digest drift. Publish creates or proves the immutable target version, then CASes
the requested `next` or `latest` channel. A latest deployment advances `next`
first only when the target is ahead, and never regresses it. OCI version images
and channel aliases are handled inside publish. No publication phase compiles
source.

Each executor writes `deployment-result.json` once, uploads it under the
candidate/step/attempt identity, obtains a GitHub OIDC token, and sends the same
bytes to Release Control. GitHub App credentials are not available to build
deployment-target/step/attempt identity, obtains a GitHub OIDC token, and sends
the same bytes to Release Control. GitHub App credentials are not available to build
shards. Effect jobs use environment-scoped Registry and container credentials.

Rust uses remote `sccache` partitioned by toolchain and target. JavaScript and
Expand Down
16 changes: 8 additions & 8 deletions .github/contracts/deployment-execution.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
"subject": {
"type": "object",
"additionalProperties": false,
"required": ["worker", "phase", "source_sha", "prepared_sha", "candidate_version", "stable_version", "descriptor_sha256"],
"required": ["worker", "phase", "source_sha", "prepared_sha", "target_version", "channel", "descriptor_sha256"],
"properties": {
"worker": { "$ref": "#/$defs/worker" },
"phase": { "enum": ["prepare", "candidate_publish", "stable_publish", "image_alias", "finalize", "verify"] },
"phase": { "enum": ["prepare", "publish", "verify"] },
"source_sha": { "$ref": "#/$defs/sha" },
"prepared_sha": { "oneOf": [{ "$ref": "#/$defs/sha" }, { "type": "null" }] },
"candidate_version": { "type": ["string", "null"], "minLength": 1 },
"stable_version": { "type": ["string", "null"], "minLength": 1 },
"target_version": { "$ref": "#/$defs/deploymentVersion" },
"channel": { "enum": ["next", "latest"] },
"descriptor_sha256": { "$ref": "#/$defs/sha256" }
}
},
Expand Down Expand Up @@ -79,16 +79,16 @@
"uuid": { "type": "string", "format": "uuid" },
"sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"deploymentVersion": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:experimental|alpha|beta))?$" },
"worker": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" },
"identity": {
"type": "object",
"additionalProperties": false,
"required": ["operation_id", "step_id", "deployment_intent_id", "candidate_id", "attempt_id", "dispatch_nonce", "plan_hash"],
"required": ["deployment_batch_id", "step_id", "deployment_target_id", "attempt_id", "dispatch_nonce", "plan_hash"],
"properties": {
"operation_id": { "$ref": "#/$defs/uuid" },
"deployment_batch_id": { "$ref": "#/$defs/uuid" },
"step_id": { "$ref": "#/$defs/uuid" },
"deployment_intent_id": { "$ref": "#/$defs/uuid" },
"candidate_id": { "$ref": "#/$defs/uuid" },
"deployment_target_id": { "$ref": "#/$defs/uuid" },
"attempt_id": { "$ref": "#/$defs/uuid" },
"dispatch_nonce": { "$ref": "#/$defs/uuid" },
"plan_hash": { "$ref": "#/$defs/sha256" }
Expand Down
4 changes: 2 additions & 2 deletions .github/deployment-control-contract.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"repository": "iii-hq/release-control",
"commit": "c6d6ff59a3071729d07600f0aa1e665d6fc7d033",
"commit": "cb940c0036192b68ce1deb9c70cdc0ffa0c438ed",
"path": "api/contracts/deployment-execution.schema.json",
"sha256": "470b340239459bcd18befbedad9ad0670ae72f6a2c214f1c7f8a581d527b28ad"
"sha256": "dc51f42d89626f0d554b94965b49aace257726a0b77010c4be05e22c6eb44196"
}
37 changes: 27 additions & 10 deletions .github/scripts/_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path
from typing import Literal

SemverKey = tuple[tuple[int, ...], int, str]
SemverKey = tuple[tuple[int, ...], int, int]
BumpKind = Literal["patch", "minor", "major"]
ManifestKind = Literal["cargo", "node", "python"]

Expand All @@ -18,6 +18,12 @@
r"(?P<patch>0|[1-9][0-9]*)"
r"(?:-(?:(?P<rc>rc)\.(?P<rc_number>[1-9][0-9]*)|(?P<maturity>experimental|alpha|beta)))?$"
)
DEPLOYMENT_TARGET_VERSION_RE = re.compile(
r"^(?:0|[1-9][0-9]*)\."
r"(?:0|[1-9][0-9]*)\."
r"(?:0|[1-9][0-9]*)"
r"(?:-(?:experimental|alpha|beta))?$"
)
RELEASE_MATURITIES = ("experimental", "alpha", "beta", "rc", "stable")
RELEASE_SUFFIXES = ("none", "experimental", "alpha", "beta")
_MATURITY_RANK = {name: idx for idx, name in enumerate(RELEASE_MATURITIES)}
Expand Down Expand Up @@ -65,6 +71,16 @@ def parse_release_version(version: str) -> ReleaseVersion:
)


def validate_deployment_target_version(version: str) -> str:
"""Validate a new deployment target without accepting legacy numbered RCs."""
if not DEPLOYMENT_TARGET_VERSION_RE.fullmatch(version):
raise ValueError(
"deployment target version must be MAJOR.MINOR.PATCH with an optional "
"-experimental, -alpha, or -beta suffix"
)
return version


def release_maturity(version: str) -> str:
return parse_release_version(version).maturity

Expand All @@ -82,8 +98,8 @@ def validate_release_transition(current: str, target: str) -> None:
raise ValueError(f"version core cannot move backwards: {current} -> {target}")
if after.core == before.core and target != current:
# A manifest at an unreleased stable core is the bootstrap point for
# its first candidate. Once a prerelease exists, movement is forward
# only through the maturity ladder and RC counter.
# its first prerelease. Once a prerelease exists, movement is forward
# only through the maturity ladder and numbered prerelease counter.
if before.maturity != "stable" and _MATURITY_RANK[after.maturity] < _MATURITY_RANK[before.maturity]:
raise ValueError(f"maturity cannot repeat or move backwards: {current} -> {target}")
if after.maturity == before.maturity == "rc" and (after.rc or 0) <= (before.rc or 0):
Expand Down Expand Up @@ -160,10 +176,8 @@ def resolve_release_version(current: str, kind: str, suffix: str, target: str =
def parse_semver(v: str) -> SemverKey:
"""Returns a tuple suitable for lexicographic compare.

Shape: (core_tuple, 1 if stable else 0, pre_suffix).
The middle int makes stable strictly greater than any pre-release at the
same core (1.2.3 > 1.2.3-rc.1). The trailing string lexically orders
multiple pre-releases at the same core (rc.1 < rc.2).
Shape: (core_tuple, product_maturity_rank, numbered_prerelease).
The product order is experimental < alpha < beta < legacy rc.N < stable.
"""
# Strip build metadata (semver 2.0.0 §10: ignored for precedence).
v_nobuild, _, _ = v.partition("+")
Expand All @@ -172,10 +186,13 @@ def parse_semver(v: str) -> SemverKey:
while len(parts) < 3:
parts.append(0)
if not pre:
return (tuple(parts), 1, "")
return (tuple(parts), 4, 0)
maturity_rank = {"experimental": 0, "alpha": 1, "beta": 2}
if pre in maturity_rank:
return (tuple(parts), maturity_rank[pre], 0)
if pre.startswith("rc.") and pre[3:].isdigit():
return (tuple(parts), 0, f"rc.{int(pre[3:]):020d}")
return (tuple(parts), 0, pre)
return (tuple(parts), 3, int(pre[3:]))
raise ValueError(f"unsupported worker prerelease version: {v}")


def bump(current: str, kind: BumpKind) -> str:
Expand Down
9 changes: 2 additions & 7 deletions .github/scripts/build_publish_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def _baseline_worker_identities(baseline_workers_json: dict[str, Any] | None) ->


# Workers the engine itself hosts (enabled via the engine config, not
# installed from the registry). A candidate install can flip one on mid-boot
# installed from the registry). A target install can flip one on mid-boot
# (e.g. harness enables `iii-stream` for console streaming), which lands it in
# the workers-baseline diff even though its interface is not part of the
# released worker's surface — and its schemas are not this repo's to fix.
Expand Down Expand Up @@ -290,7 +290,6 @@ def build_payload(
repo_url: str,
interface: dict[str, Any],
artifacts: dict[str, Any],
registry_tag: str,
readme: str | None = None,
) -> dict[str, Any]:
"""Merge the immutable compiler projection with current Registry fields."""
Expand All @@ -300,7 +299,7 @@ def build_payload(
}
if not isinstance(registry_projection, dict) or set(registry_projection) != required:
raise ValueError("registry_projection differs from the current Registry metadata contract")
_lib.parse_release_version(published_version)
_lib.validate_deployment_target_version(published_version)
deploy = registry_projection["type"]
kind = artifacts.get("kind")
expected_kind = {
Expand All @@ -323,8 +322,6 @@ def build_payload(
for trigger in interface.get("triggers") or []
],
}
if registry_tag != "none":
payload["tag"] = registry_tag
if deploy == "binary":
binaries = artifacts.get("binaries")
if not isinstance(binaries, dict) or not binaries:
Expand Down Expand Up @@ -353,7 +350,6 @@ def main() -> int:
parser.add_argument("--repo-url", required=True)
parser.add_argument("--interface-json", required=True)
parser.add_argument("--artifacts-json", required=True)
parser.add_argument("--registry-tag", required=True)
parser.add_argument("--readme")
parser.add_argument("--out", default="payload.json")
args = parser.parse_args()
Expand All @@ -369,7 +365,6 @@ def main() -> int:
repo_url=args.repo_url,
interface=interface,
artifacts=artifacts,
registry_tag=args.registry_tag,
readme=readme,
)
pathlib.Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
Expand Down
Loading
Loading