diff --git a/.github/scripts/_lib.py b/.github/scripts/_lib.py index d0af66bd8..078e5bf2b 100644 --- a/.github/scripts/_lib.py +++ b/.github/scripts/_lib.py @@ -4,6 +4,7 @@ import json import re import subprocess +from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -12,6 +13,11 @@ BumpKind = Literal["patch", "minor", "major"] ManifestKind = Literal["cargo", "node", "python"] +# Pre-release suffixes offered by the Create Tag workflow. `stable` is not a +# suffix but the promotion path (drop the pre-release, keep the base version), +# so it lives in the same input. +PRERELEASE_SUFFIXES = ("alpha", "beta", "rc") + def parse_semver(v: str) -> SemverKey: """Returns a tuple suitable for lexicographic compare. @@ -52,6 +58,45 @@ def bump(current: str, kind: BumpKind) -> str: return f"{major}.{minor}.{patch}" +def core_version(v: str) -> str: + """Returns `v` without its pre-release / build suffix (1.2.3-rc.1 -> 1.2.3).""" + return v.partition("-")[0].partition("+")[0] + + +def next_prerelease(base: str, suffix: str, existing: Iterable[str]) -> str: + """Returns `base-suffix.N`, one past the highest N already released. + + `existing` is every version already tagged for the worker; only entries + matching this exact base and suffix count, so alpha and beta lines at the + same base advance independently. + """ + pattern = re.compile(rf"^{re.escape(base)}-{re.escape(suffix)}\.(\d+)$") + highest = 0 + for version in existing: + m = pattern.match(version.strip()) + if m: + highest = max(highest, int(m.group(1))) + return f"{base}-{suffix}.{highest + 1}" + + +def list_tagged_versions(worker: str) -> list[str]: + """Versions already tagged for `worker`, from git tags `/v`. + + Returns [] when git is unavailable or the worker has no tags yet, so a + first pre-release starts its counter at 1. + """ + prefix = f"{worker}/v" + try: + out = subprocess.check_output( + ["git", "tag", "--list", f"{prefix}*"], + text=True, + stderr=subprocess.DEVNULL, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + return [line[len(prefix):] for line in out.splitlines() if line.startswith(prefix)] + + def detect_kind(manifest_path: Path) -> ManifestKind: """Identifies a manifest file by its filename.""" name = manifest_path.name diff --git a/.github/scripts/manifest_version.py b/.github/scripts/manifest_version.py index ce47b2203..100f36cc9 100644 --- a/.github/scripts/manifest_version.py +++ b/.github/scripts/manifest_version.py @@ -4,6 +4,7 @@ Subcommands: read print the manifest's version to stdout bump --kind ... bump the version in-place + [--suffix alpha|beta|rc|stable|none --worker NAME] verify --expected V assert the file's version equals V deploy-mode print the interface-collection mode @@ -31,11 +32,28 @@ def cmd_read(args: argparse.Namespace) -> int: return 0 +def _resolve_version(current: str, kind: str, suffix: str, worker: str | None) -> str: + """Applies `kind` (version bump) and `suffix` (pre-release line) to `current`. + + The two are independent: `kind` picks the base version, `suffix` decides + whether that base ships as a pre-release. `none` leaves the manifest value + alone (a merged PR may have set it); `stable` promotes a pre-release to its + base without bumping (1.2.3-rc.2 -> 1.2.3). + """ + if suffix in _lib.PRERELEASE_SUFFIXES: + base = _lib.core_version(current) if kind == "none" else _lib.bump(current, kind) + existing = _lib.list_tagged_versions(worker) if worker else [] + return _lib.next_prerelease(base, suffix, existing) + if suffix == "stable": + return _lib.core_version(current) if kind == "none" else _lib.bump(current, kind) + return current if kind == "none" else _lib.bump(current, kind) + + def cmd_bump(args: argparse.Namespace) -> int: path = Path(args.manifest) try: current = _lib.read_version(path) - new = current if args.kind == "none" else _lib.bump(current, args.kind) + new = _resolve_version(current, args.kind, args.suffix, args.worker) _lib.write_version(path, new) except (FileNotFoundError, ValueError) as e: print(f"error: {e}", file=sys.stderr) @@ -131,6 +149,16 @@ def main(argv: list[str] | None = None) -> int: p_bump = sub.add_parser("bump", help="bump the manifest version in place") p_bump.add_argument("manifest") p_bump.add_argument("--kind", choices=["patch", "minor", "major", "none"], required=True) + p_bump.add_argument( + "--suffix", + choices=["none", "stable", *_lib.PRERELEASE_SUFFIXES], + default="none", + help="pre-release line for the bumped version (none = leave as-is)", + ) + p_bump.add_argument( + "--worker", + help="worker name; used to read existing git tags when numbering a pre-release", + ) p_bump.set_defaults(func=cmd_bump) p_verify = sub.add_parser("verify", help="assert the manifest version equals --expected") diff --git a/.github/scripts/parse_release_tag.py b/.github/scripts/parse_release_tag.py index 26f462e34..28f4cc634 100644 --- a/.github/scripts/parse_release_tag.py +++ b/.github/scripts/parse_release_tag.py @@ -21,6 +21,13 @@ DRY_RUN_RE = re.compile(r"-dry-run\.\d+$") PRERELEASE_RE = re.compile(r"-[a-z]+\.\d+$") +# Distribution channels, orthogonal to the version's pre-release suffix: a +# release is `@`, e.g. 1.2.3-rc.1@next. The registry stores +# `registry-tag` verbatim (a free-form string column), so a typo in the +# annotated tag message would silently create a dead channel that nothing +# resolves. Keep the accepted set closed here, matching the Create Tag options. +RELEASE_CHANNELS = ("latest", "next", "experimental") + def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser() @@ -56,6 +63,13 @@ def main(argv: list[str] | None = None) -> int: return 1 registry_tag = _lib.read_tag_annotation(raw).get("registry-tag", "latest") or "latest" + if registry_tag not in RELEASE_CHANNELS: + print( + f"::error::Unknown registry-tag {registry_tag!r} in the annotated tag " + f"message; expected one of {', '.join(RELEASE_CHANNELS)}", + file=sys.stderr, + ) + return 1 # `targets` is an optional iii.worker.yaml field that can be either a # list (`- aarch64-apple-darwin\n- x86_64-unknown-linux-gnu`) or a comma diff --git a/.github/scripts/tests/conftest.py b/.github/scripts/tests/conftest.py index ad084f9ad..d13f16cf9 100644 --- a/.github/scripts/tests/conftest.py +++ b/.github/scripts/tests/conftest.py @@ -54,6 +54,26 @@ def pyproject_manifest(tmp_path: Path) -> Path: return p +@pytest.fixture +def git_repo_manifest(tmp_path: Path) -> tuple[Path, Path]: + """A git repo with a committed Cargo.toml at 0.1.0, for pre-release numbering. + + Returns (repo_dir, manifest_path). Tests add `/v` tags to + the repo to exercise the counter `manifest_version.py bump --worker` reads. + """ + def git(*args: str) -> None: + subprocess.run(args, cwd=tmp_path, check=True, env=GIT_HERMETIC_ENV) + + git("git", "init", "-q", "-b", "main") + git("git", "config", "user.email", "test@example.com") + git("git", "config", "user.name", "Test") + manifest = tmp_path / "Cargo.toml" + manifest.write_text('[package]\nname = "smoke"\nversion = "0.1.0"\nedition = "2021"\n') + git("git", "add", ".") + git("git", "commit", "-q", "-m", "init") + return tmp_path, manifest + + @pytest.fixture def iii_worker_yaml_dir(tmp_path: Path) -> Path: """Returns a tmp dir containing a minimal iii.worker.yaml (rust binary).""" diff --git a/.github/scripts/tests/test_manifest_version.py b/.github/scripts/tests/test_manifest_version.py index 1bf6f1638..edc0af215 100644 --- a/.github/scripts/tests/test_manifest_version.py +++ b/.github/scripts/tests/test_manifest_version.py @@ -5,15 +5,29 @@ import sys from pathlib import Path +import pytest + +from _test_helpers import GIT_HERMETIC_ENV + SCRIPT = Path(__file__).resolve().parents[1] / "manifest_version.py" -def run_script(*args: str) -> subprocess.CompletedProcess[str]: +def run_script(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: """Run manifest_version.py with arguments; capture stdout/stderr/exit.""" return subprocess.run( [sys.executable, str(SCRIPT), *args], capture_output=True, text=True, + cwd=cwd, + env=GIT_HERMETIC_ENV, + ) + + +def tag(repo: Path, name: str) -> None: + """Create an annotated tag in `repo` (the shape create-tag.yml pushes).""" + subprocess.run( + ["git", "tag", "-a", name, "-m", f"Release {name}\n\nregistry-tag: next\n"], + cwd=repo, check=True, env=GIT_HERMETIC_ENV, ) @@ -68,6 +82,76 @@ def test_bump_rejects_unknown_kind(self, cargo_manifest): r = run_script("bump", str(cargo_manifest), "--kind", "weird") assert r.returncode != 0 + def test_bump_defaults_to_no_suffix(self, cargo_manifest): + r = run_script("bump", str(cargo_manifest), "--kind", "patch") + assert r.stdout.strip() == "0.1.1" + + +class TestBumpSuffix: + """`--suffix` picks the pre-release line; `--kind` still picks the base.""" + + @pytest.mark.parametrize("suffix", ["alpha", "beta", "rc"]) + def test_suffix_starts_counter_at_one(self, cargo_manifest, suffix): + r = run_script("bump", str(cargo_manifest), "--kind", "patch", "--suffix", suffix) + assert r.returncode == 0, r.stderr + assert r.stdout.strip() == f"0.1.1-{suffix}.1" + + def test_suffix_with_kind_none_keeps_base(self, cargo_manifest): + """Iterating a pre-release must not walk the base version forward.""" + r = run_script("bump", str(cargo_manifest), "--kind", "none", "--suffix", "alpha") + assert r.stdout.strip() == "0.1.0-alpha.1" + + def test_suffix_strips_existing_prerelease_before_bumping(self, cargo_manifest): + run_script("bump", str(cargo_manifest), "--kind", "none", "--suffix", "alpha") + r = run_script("bump", str(cargo_manifest), "--kind", "patch", "--suffix", "beta") + assert r.stdout.strip() == "0.1.1-beta.1" + + def test_stable_promotes_prerelease_to_base(self, cargo_manifest): + run_script("bump", str(cargo_manifest), "--kind", "none", "--suffix", "rc") + r = run_script("bump", str(cargo_manifest), "--kind", "none", "--suffix", "stable") + assert r.stdout.strip() == "0.1.0" + + def test_stable_on_stable_version_is_a_noop(self, cargo_manifest): + r = run_script("bump", str(cargo_manifest), "--kind", "none", "--suffix", "stable") + assert r.stdout.strip() == "0.1.0" + + def test_rejects_unknown_suffix(self, cargo_manifest): + r = run_script("bump", str(cargo_manifest), "--kind", "patch", "--suffix", "gamma") + assert r.returncode != 0 + + +class TestPrereleaseCounter: + """`--worker` numbers the pre-release from tags already in the repo.""" + + def test_counter_continues_from_existing_tags(self, git_repo_manifest): + repo, manifest = git_repo_manifest + tag(repo, "smoke/v0.1.1-alpha.1") + tag(repo, "smoke/v0.1.1-alpha.2") + r = run_script("bump", str(manifest), "--kind", "patch", + "--suffix", "alpha", "--worker", "smoke", cwd=repo) + assert r.stdout.strip() == "0.1.1-alpha.3" + + def test_counter_ignores_other_suffixes_at_same_base(self, git_repo_manifest): + repo, manifest = git_repo_manifest + tag(repo, "smoke/v0.1.1-alpha.4") + r = run_script("bump", str(manifest), "--kind", "patch", + "--suffix", "beta", "--worker", "smoke", cwd=repo) + assert r.stdout.strip() == "0.1.1-beta.1" + + def test_counter_ignores_other_workers(self, git_repo_manifest): + repo, manifest = git_repo_manifest + tag(repo, "other/v0.1.1-alpha.9") + r = run_script("bump", str(manifest), "--kind", "patch", + "--suffix", "alpha", "--worker", "smoke", cwd=repo) + assert r.stdout.strip() == "0.1.1-alpha.1" + + def test_counter_ignores_other_base_versions(self, git_repo_manifest): + repo, manifest = git_repo_manifest + tag(repo, "smoke/v0.2.0-alpha.7") + r = run_script("bump", str(manifest), "--kind", "patch", + "--suffix", "alpha", "--worker", "smoke", cwd=repo) + assert r.stdout.strip() == "0.1.1-alpha.1" + class TestVerifySubcommand: def test_verify_match(self, cargo_manifest): diff --git a/.github/scripts/tests/test_parse_release_tag.py b/.github/scripts/tests/test_parse_release_tag.py index 6a6ccc79f..a82103ff7 100644 --- a/.github/scripts/tests/test_parse_release_tag.py +++ b/.github/scripts/tests/test_parse_release_tag.py @@ -82,6 +82,47 @@ def test_prerelease_sets_is_prerelease(self, tmp_path): assert out["dry_run"] == "false" assert out["registry_tag"] == "next" + def test_experimental_channel(self, tmp_path): + repo = make_repo_with_tagged_worker( + tmp_path, "smoke/v1.2.3", "1.2.3", + registry_tag_line="registry-tag: experimental") + out_path = tmp_path / "gh_output" + out_path.touch() + r = run_script(repo, "smoke/v1.2.3", out_path) + assert r.returncode == 0, r.stderr + assert parse_outputs(out_path)["registry_tag"] == "experimental" + + # A suffixed version is orthogonal to the channel: it ships on whichever + # channel the tag message names, and still marks the GitHub Release as a + # prerelease. + @pytest.mark.parametrize("suffix", ["alpha", "beta", "rc"]) + def test_suffixed_version_on_next_channel(self, tmp_path, suffix): + version = f"1.2.3-{suffix}.1" + repo = make_repo_with_tagged_worker( + tmp_path, f"smoke/v{version}", version, + registry_tag_line="registry-tag: next") + out_path = tmp_path / "gh_output" + out_path.touch() + r = run_script(repo, f"smoke/v{version}", out_path) + assert r.returncode == 0, r.stderr + out = parse_outputs(out_path) + assert out["version"] == version + assert out["registry_tag"] == "next" + assert out["is_prerelease"] == "true" + + # `alpha` is a version suffix, never a channel; accepting it here would + # publish a channel the registry resolves for nobody. + @pytest.mark.parametrize("bad", ["alpha", "latests", "stable"]) + def test_unknown_channel_fails(self, tmp_path, bad): + repo = make_repo_with_tagged_worker( + tmp_path, "smoke/v1.2.3", "1.2.3", + registry_tag_line=f"registry-tag: {bad}") + out_path = tmp_path / "gh_output" + out_path.touch() + r = run_script(repo, "smoke/v1.2.3", out_path) + assert r.returncode == 1 + assert "Unknown registry-tag" in r.stderr + def test_dry_run_tag(self, tmp_path): repo = make_repo_with_tagged_worker(tmp_path, "smoke/v9.9.9-dry-run.1", "9.9.9-dry-run.1") out_path = tmp_path / "gh_output" diff --git a/.github/workflows/_container.yml b/.github/workflows/_container.yml index 4e8625532..7cac69aae 100644 --- a/.github/workflows/_container.yml +++ b/.github/workflows/_container.yml @@ -12,7 +12,7 @@ on: required: true type: string registry_tag: - description: 'Registry tag to also push (latest, next, ...)' + description: 'Registry channel to also push as an image tag (latest, next, experimental)' required: false type: string default: latest diff --git a/.github/workflows/_publish-registry.yml b/.github/workflows/_publish-registry.yml index ed3848d66..ca01d363d 100644 --- a/.github/workflows/_publish-registry.yml +++ b/.github/workflows/_publish-registry.yml @@ -16,7 +16,7 @@ on: required: true type: string registry_tag: - description: 'Registry tag (latest, next, ...)' + description: 'Registry channel (latest, next, experimental)' required: false type: string default: latest diff --git a/.github/workflows/_publish-worker-skills.yml b/.github/workflows/_publish-worker-skills.yml index 30c040b16..fbd6d7ed8 100644 --- a/.github/workflows/_publish-worker-skills.yml +++ b/.github/workflows/_publish-worker-skills.yml @@ -8,7 +8,7 @@ on: required: true type: string version: - description: 'Registry tag channel (latest, next, ...)' + description: 'Registry channel (latest, next, experimental)' required: true type: string api_url: diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 6628f0e32..8e9627bbd 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -68,13 +68,25 @@ on: - major - none default: patch + suffix: + description: 'Pre-release suffix (none = leave the version stable; stable = promote a pre-release to its base version)' + required: true + type: choice + options: + - none + - alpha + - beta + - rc + - stable + default: none tag: - description: 'Registry tag (passed to POST /publish; not part of git tag name)' + description: 'Registry channel the version is published under (passed to POST /publish; not part of the git tag name)' required: true type: choice options: - latest - next + - experimental default: latest permissions: @@ -145,15 +157,19 @@ jobs: env: WORKER: ${{ inputs.worker }} BUMP: ${{ inputs.bump }} + SUFFIX: ${{ inputs.suffix }} MANIFEST: ${{ steps.meta.outputs.manifest }} run: | set -euo pipefail current=$(python3 .github/scripts/manifest_version.py read "$WORKER/$MANIFEST") - new_ver=$(python3 .github/scripts/manifest_version.py bump "$WORKER/$MANIFEST" --kind "$BUMP") + # --worker lets the script number the pre-release from existing tags + # (checkout above is fetch-depth 0, so they are all present). + new_ver=$(python3 .github/scripts/manifest_version.py bump "$WORKER/$MANIFEST" \ + --kind "$BUMP" --suffix "$SUFFIX" --worker "$WORKER") echo "current=$current" >> "$GITHUB_OUTPUT" echo "version=$new_ver" >> "$GITHUB_OUTPUT" echo "tag=${WORKER}/v${new_ver}" >> "$GITHUB_OUTPUT" - echo "::notice::${WORKER}: ${current} -> ${new_ver}" + echo "::notice::${WORKER}: ${current} -> ${new_ver} (channel: ${{ inputs.tag }})" - name: Validate manifest update env: diff --git a/.github/workflows/publish-worker-skills.yml b/.github/workflows/publish-worker-skills.yml index 1e0692e08..c60b3f7af 100644 --- a/.github/workflows/publish-worker-skills.yml +++ b/.github/workflows/publish-worker-skills.yml @@ -14,6 +14,7 @@ on: options: - latest - next + - experimental default: latest concurrency: diff --git a/docs/sops/release.md b/docs/sops/release.md index cd28d44f2..037ec323a 100644 --- a/docs/sops/release.md +++ b/docs/sops/release.md @@ -34,15 +34,22 @@ Actions → **Create Tag**: | Input | Meaning | |---|---| | Worker | Folder name (must be in workflow options) | -| Bump | `patch` / `minor` / `major` | -| Registry tag | `latest` or `next` — channel for `iii worker add` resolution | +| Bump | `patch` / `minor` / `major` / `none` — picks the base version | +| Suffix | `none` / `alpha` / `beta` / `rc` / `stable` — pre-release line on that base | +| Registry tag | `latest` / `next` / `experimental` — channel the version publishes to | + +**Suffix and Registry tag are independent axes.** The suffix lives in the +version (`1.2.3-rc.1`); the channel is where that version is published +(`@next`). Any combination is valid — a release is `@`, e.g. +`1.2.3-rc.1@next`. See [Version suffixes](#version-suffixes) and +[Registry tag semantics](#4-registry-tag-semantics). The workflow: 1. Bumps version in the worker manifest (`Cargo.toml`, `package.json`, …). 2. Commits `chore(): bump to vX.Y.Z` to `main`. 3. Creates and pushes an **annotated** tag `/vX.Y.Z` with - `registry-tag: ` in the tag message. + `registry-tag: ` in the tag message. ### 2. Release pipeline @@ -92,14 +99,61 @@ Workers with `interface_smoke: false` skip the entire publish job. ### 4. Registry tag semantics +A channel is *where a version is published*, written `@`. + | Channel | Typical use | |---|---| | `latest` | Default; what most `iii worker add` installs resolve | -| `next` | Pre-release / risky channel; safer for first publish | +| `next` | Upcoming release; safer for a first publish | +| `experimental` | Throwaway / spike work not intended for promotion | + +A worker version carries **at most one** channel, and a worker has at most one +version per channel. Publishing moves the channel: the previous holder loses it +(`clearTagOnWorker` in the registry), so channels are reassigned on each release. The channel is stored in the **annotated tag message** (`registry-tag:`). `release.yml` refetches the annotated tag for this reason. Lightweight tags -lose the channel and default to `latest`. +lose the channel and default to `latest`. `parse_release_tag.py` rejects any +value outside the table above, so a typo fails the release instead of creating +a dead channel that nothing resolves. + +> **Note:** installs resolve `latest` only. `next` and `experimental` are +> published and queryable by tag through the registry API, but +> `iii worker add @next` needs resolver support in `iii-hq/registry` +> before it works. + +### Version suffixes + +A suffix is *what the version is*, written into the version itself. It is +orthogonal to the channel — pick both independently. + +| Suffix | Result from `1.2.3` | Meaning | +|---|---|---| +| `none` | `1.2.4` | Stable release (default) | +| `alpha` | `1.2.4-alpha.1` | Earliest, expected to break | +| `beta` | `1.2.4-beta.1` | Feature-complete but unstable | +| `rc` | `1.2.4-rc.1` | Release candidate | +| `stable` | `1.2.3` | Promote a pre-release to its base, no bump | + +The counter is derived from existing git tags, so re-running Create Tag with +the same worker, base and suffix advances it (`-rc.1` → `-rc.2`) instead of +colliding. Each suffix line advances independently at the same base. + +Bump and suffix compose: **Bump** picks the base version, **Suffix** decides +whether that base ships as a pre-release. `Bump: none` keeps the current base, +which is how you iterate a pre-release without walking the version forward. + +A typical `rc` cycle, all on `@next`, then promoted: + +```text +Bump: patch Suffix: rc Tag: next -> 1.2.4-rc.1@next +Bump: none Suffix: rc Tag: next -> 1.2.4-rc.2@next +Bump: none Suffix: stable Tag: latest -> 1.2.4@latest +``` + +Pre-release versions are marked as prereleases on the GitHub Release and are +skipped by the registry resolver's semver matching, so a `^1.2.0` dependency +never silently resolves to `1.2.4-rc.2`. ## Variants @@ -111,14 +165,13 @@ Concurrency group `release-${{ github.ref }}` serializes per tag. ### Prerelease -Create Tag cannot produce prerelease suffixes. Push a manual **annotated** tag: - -```text -/vX.Y.Z-beta.1 -``` +Use Create Tag's **Suffix** input — see [Version suffixes](#version-suffixes). -With tag message including `registry-tag: next`. Marks the GitHub Release as -prerelease; still builds and publishes (unless `interface_smoke: false`). +To cut one by hand instead, push an **annotated** tag shaped +`/vX.Y.Z-beta.1` with `registry-tag: ` in the message. Either +way the GitHub Release is marked prerelease and still builds and publishes +(unless `interface_smoke: false`). A hand-pushed tag must carry the `.N` +counter — `parse_release_tag.py` detects prereleases as `-.`. ### Dry run @@ -190,7 +243,8 @@ There is **no unpublish**. Recovery: 1. Fix the issue on `main`. 2. Cut a new patch via Create Tag (registry `latest` moves forward). -3. Use `registry-tag: next` when uncertain before promoting to `latest`. +3. When uncertain, cut an `rc` suffix on the `next` channel first, then + promote with `Suffix: stable` / `Tag: latest`. GitHub Release assets for the bad version remain (immutable history).