diff --git a/apps/desktop/electron/updater/channel-protocol.ts b/apps/desktop/electron/updater/channel-protocol.ts index 800ad3fc93..4fa892b284 100644 --- a/apps/desktop/electron/updater/channel-protocol.ts +++ b/apps/desktop/electron/updater/channel-protocol.ts @@ -33,6 +33,8 @@ export interface ChannelRequest extends ChannelBuild { schema: 1 controllerCommit?: string releaseTag?: string + /** Attempt ref naming the immutable archive; only the releases/tag/ prefix reads it. */ + archiveRef?: string } export interface ChannelHead { buildId: string @@ -86,6 +88,9 @@ const SHA256 = /^[a-f0-9]{64}$/ const COMMIT = /^[a-f0-9]{40}$/ const BUILD_ID = /^[a-f0-9]{32}$/ const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/ +// rc.-vX.Y.Z with N >= 1 without leading zeros and a release version whose +// major stays within three digits — one regex matching the Python grammar. +const ARCHIVE_REF = /^rc\.(?:[1-9]\d*)-v(?:0|[1-9]\d{0,2})\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/ function parseChannelJson(body: string): unknown { const parsed: unknown = JSON.parse(body) @@ -405,7 +410,8 @@ function request(fields: Fields): ChannelRequest { bundleEnv, identity: identity(fields.object('identity')), controllerCommit: fields.optional('controllerCommit', COMMIT), - releaseTag + releaseTag, + archiveRef: fields.optional('archiveRef', ARCHIVE_REF) } } diff --git a/apps/desktop/electron/updater/channel.test.ts b/apps/desktop/electron/updater/channel.test.ts index f0d65becf1..98b6dc11e8 100644 --- a/apps/desktop/electron/updater/channel.test.ts +++ b/apps/desktop/electron/updater/channel.test.ts @@ -7,7 +7,7 @@ import feedContract from '../../update-feed.cjs' import { ChannelResolver } from './channel' import type { ChannelBuild, ChannelManifest, ChannelRecord, RetiredChannel } from './channel-protocol' -import { decodeChannelRecord, sameChannelIdentity } from './channel-protocol' +import { decodeChannelManifest, decodeChannelRecord, sameChannelIdentity } from './channel-protocol' import { ChannelStrategy } from './channel-strategy' const servers: Server[] = [] @@ -246,6 +246,75 @@ test('protected canary accepts bounded Windows revisions without relaxing stable expect((await resolver.resolve()).kind).toBe('active') }) +test.each(['rc.1-v0.21.5', 'rc.12-v1.0.0'] as const)( + 'admits attempt archive ref %j as the protected prefix', + async (attempt): Promise => { + const f = await fixture() + f.record.policy = 'stable-release' + Object.assign(f.manifest.request, { + releaseTag: 'v0.21.5', + version: '0.21.5', + windowsVersion: '0.21.5.0', + archiveRef: attempt + }) + f.manifest.packages[0].version = '0.21.5' + f.manifest.packages[0].artifact.key = `releases/tag/${attempt}/darwin/Hermes.zip` + f.manifest.packages[0].feed.key = `releases/tag/${attempt}/darwin/stable-mac.yml` + f.publish() + const result = await new ChannelResolver({ + build: f.build, + platform: 'darwin', + arch: 'arm64', + signer: 'ABCDE12345' + }).resolve() + expect(result.kind).toBe('active') + } +) + +test('the protected archive prefix falls back closed to the bare release tag', async (): Promise => { + const f = await fixture() + f.record.policy = 'stable-release' + Object.assign(f.manifest.request, { releaseTag: 'v1.2.3', version: '1.2.3', windowsVersion: '1.2.3.0' }) + f.manifest.packages[0].version = '1.2.3' + const attempt = 'rc.2-v1.2.3' + f.manifest.packages[0].artifact.key = `releases/tag/${attempt}/darwin/Hermes.zip` + f.manifest.packages[0].feed.key = `releases/tag/${attempt}/darwin/stable-mac.yml` + const resolver = new ChannelResolver({ build: f.build, platform: 'darwin', arch: 'arm64', signer: 'ABCDE12345' }) + // A stable request without archiveRef can never admit attempt-scoped bytes. + f.publish() + await expect(resolver.resolve()).rejects.toThrow(/prefix/) + // An archiveRef naming another version points at a different archive. + f.manifest.request.archiveRef = 'rc.2-v1.2.4' + f.publish() + await expect(resolver.resolve()).rejects.toThrow(/prefix/) + f.manifest.request.archiveRef = attempt + f.publish() + expect((await resolver.resolve()).kind).toBe('active') +}) + +test('archiveRef parses the shared attempt-ref grammar or is refused', async (): Promise => { + const f = await fixture() + const decode = (): unknown => decodeChannelManifest(JSON.stringify(f.manifest)) + for (const ref of ['rc.1-v0.21.5', 'rc.12-v1.0.0']) { + f.manifest.request.archiveRef = ref + expect(decode).not.toThrow() + } + for (const ref of [ + 'v0.21.5-rc', + 'v0.21.5-rc.1', + 'rc.01-v0.21.5', + 'rc.0-v0.21.5', + 'rc.1-v2026.9.21', + 'v0.21.5', + 'abandoned-rc.1-v0.21.5' + ]) { + f.manifest.request.archiveRef = ref + expect(decode).toThrow(/archiveRef/) + } + delete f.manifest.request.archiveRef + expect(decode).not.toThrow() +}) + test('offers a digest-bound retirement without a second proof document', async (): Promise => { const { build, retired, requests } = await retiredFixture() const result = await new ChannelResolver({ build, platform: 'darwin', arch: 'arm64', signer: 'ABCDE12345' }).resolve() diff --git a/apps/desktop/electron/updater/channel.ts b/apps/desktop/electron/updater/channel.ts index 0e9b1d86bc..1767d7dd51 100644 --- a/apps/desktop/electron/updater/channel.ts +++ b/apps/desktop/electron/updater/channel.ts @@ -241,7 +241,9 @@ export class ChannelResolver { throw new Error('Protected release version mismatch') } - prefixes.push(`releases/tag/${request.releaseTag}/`) + // The archive prefix is the attempt ref when the request names one; a + // bare releaseTag fallback never holds attempt artifacts (fail closed). + prefixes.push(`releases/tag/${request.archiveRef ?? request.releaseTag}/`) } for (const entry of manifest.packages) { diff --git a/hermes_cli/release_channels.py b/hermes_cli/release_channels.py index c8811ec9df..088a1ffc3b 100644 --- a/hermes_cli/release_channels.py +++ b/hermes_cli/release_channels.py @@ -155,6 +155,15 @@ def validate_request(value: object, *, repository: str | None = None, ) if request.get("version") != request["releaseTag"][1:]: raise ChannelError("Release package version mismatch") + archive_ref = request.get("archiveRef") + if archive_ref is not None: + from scripts.releases.versioning import parse_attempt_ref + parsed = parse_attempt_ref(archive_ref) if isinstance(archive_ref, str) else None + if parsed is None: + if archive_ref != request["releaseTag"]: + raise ChannelError("Invalid archive ref") + elif parsed[0] != request["version"]: + raise ChannelError("Archive ref does not name the release version") # Stable uses the Store quad with revision zero; canary uses its UTC # yy.mmdd.hh.mmss package version. pattern = r"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" if policy == "canary-release" else r"[0-9]+\.[0-9]+\.[0-9]+\.0" @@ -322,7 +331,9 @@ def validate_manifest(value: object, record: dict, base_url: str) -> dict: raise ChannelError("Build manifest has no packages") prefixes = [build_prefix(request["buildId"])] if record["policy"] in {"stable-release", "canary-release"}: - prefixes.append(f"releases/tag/{request['releaseTag']}/") + # The archive prefix is the attempt ref when the request names one; a + # bare releaseTag fallback never holds attempt artifacts (fail closed). + prefixes.append(f"releases/tag/{request.get('archiveRef') or request['releaseTag']}/") seen = set() for package in packages: if not isinstance(package, dict): diff --git a/scripts/releases/channel_releases.py b/scripts/releases/channel_releases.py index af49033a0b..164545f2dc 100644 --- a/scripts/releases/channel_releases.py +++ b/scripts/releases/channel_releases.py @@ -373,7 +373,8 @@ def publish_release(policy: str, env: dict, root: Path) -> dict: source_version = payload_tag[1:].split("+", 1)[0] request = publisher.allocate_protected(name, commit, source_version, release_tag=payload_tag, version=payload_tag[1:], windows_version=windows["version"], - identity=identity, policy=policy, release_gate=release_gate) + identity=identity, policy=policy, release_gate=release_gate, + archive_ref=tag if parsed else None) manifest, feeds = assemble(request, native, root, artifact_prefix=f"releases/tag/{tag}/") if accepted is not None: match_accepted_packages(manifest, accepted) diff --git a/scripts/releases/channels.py b/scripts/releases/channels.py index c91aab55d5..d6a7ffe26f 100644 --- a/scripts/releases/channels.py +++ b/scripts/releases/channels.py @@ -214,12 +214,15 @@ class ChannelPublisher: def allocate_protected(self, name: str, commit: str, source_version: str, *, release_tag: str, version: str, windows_version: str, - identity: dict, policy: str, release_gate) -> dict: + identity: dict, policy: str, release_gate, + archive_ref: str | None = None) -> dict: """Reserve accepted legacy bytes, never authorize a custom build as stable.""" facts = {"schema": 1, "channel": name, "repository": self.repository, "commit": commit, "sourceVersion": source_version, "version": version, "windowsVersion": windows_version, "releaseTag": release_tag, "identity": deepcopy(identity), "bundleEnv": {}, "publicBase": self.public_base} + if archive_ref is not None: + facts["archiveRef"] = archive_ref build_id = hashlib.sha256(canonical_json(facts)).hexdigest()[:32] key = build_prefix(build_id) + "request.json" for _ in range(16): diff --git a/tests/hermes_cli/test_release_channels.py b/tests/hermes_cli/test_release_channels.py index 78783bc2e3..97c0698a37 100644 --- a/tests/hermes_cli/test_release_channels.py +++ b/tests/hermes_cli/test_release_channels.py @@ -189,3 +189,74 @@ def test_malformed_wire_types_are_channel_errors(field, value): record[field] = value with pytest.raises(ChannelError): validate_record(record) + + +def stable_request(version="1.2.3", archive_ref=None): + from scripts.releases.channels import preview_identity + request = {"schema": 1, "buildId": "a" * 32, "channel": "stable", "sequence": 1, + "repository": "example/hermes-agent", "commit": "b" * 40, + "sourceVersion": version, "version": version, "windowsVersion": version + ".0", + "releaseTag": "v" + version, "identity": preview_identity("stable", "a" * 16), + "bundleEnv": {}, "publicBase": "https://releases.example"} + if archive_ref is not None: + request["archiveRef"] = archive_ref + return request + + +def stable_manifest(request, archive_prefix): + from hermes_cli.release_channels import build_prefix + manifest = {"schema": 1, "receiverProtocol": 1, "request": request, "packages": [ + {"platform": "darwin", "arch": "arm64", "variant": "bundled", + "identity": request["identity"]["appId"], "version": request["version"], "teamId": "ABCDEFGHIJ", + "artifact": {"key": archive_prefix + "Hermes.dmg", "sha256": "d" * 64, "size": 100}, + "feed": {"key": archive_prefix + "stable-mac.yml", "channel": "stable"}}]} + record = {"schema": 1, "name": request["channel"], "repository": request["repository"], + "policy": "stable-release", "state": "active", "revision": 1, "nextSequence": 2, + "identity": request["identity"], + "head": {"buildId": request["buildId"], "sequence": request["sequence"], + "manifestKey": build_prefix(request["buildId"]) + "build.json", "sha256": "a" * 64}} + return manifest, record + + +def test_archive_ref_names_the_protected_archive_prefix(): + from hermes_cli.release_channels import validate_manifest, validate_request + request = stable_request(archive_ref="rc.2-v1.2.3") + validate_request(request, policy="stable-release") + manifest, record = stable_manifest(request, "releases/tag/rc.2-v1.2.3/") + assert validate_manifest(manifest, record, request["publicBase"]) == manifest + + +def test_stable_manifest_without_archive_ref_fails_closed_outside_the_tag_prefix(): + from hermes_cli.release_channels import validate_manifest, ChannelError + request = stable_request() + manifest, record = stable_manifest(request, "releases/tag/rc.2-v1.2.3/") + with pytest.raises(ChannelError, match="namespace"): + validate_manifest(manifest, record, request["publicBase"]) + + +def test_archive_ref_equal_to_the_release_tag_keeps_the_tag_prefix(): + from hermes_cli.release_channels import validate_manifest, validate_request + request = stable_request(archive_ref="v1.2.3") + validate_request(request, policy="stable-release") + manifest, record = stable_manifest(request, "releases/tag/v1.2.3/") + assert validate_manifest(manifest, record, request["publicBase"]) == manifest + + +def test_archive_ref_must_name_the_release_version(): + from hermes_cli.release_channels import validate_request, ChannelError + with pytest.raises(ChannelError, match="(?i)archive ref"): + validate_request(stable_request(archive_ref="rc.2-v1.2.4"), policy="stable-release") + + +@pytest.mark.parametrize("ref", ["rc.1-v0.21.5", "rc.12-v1.0.0"]) +def test_attempt_ref_shapes_are_attempt_refs(ref): + from scripts.releases.versioning import parse_attempt_ref + assert parse_attempt_ref(ref) is not None + + +@pytest.mark.parametrize("ref", ["v0.21.5-rc", "v0.21.5-rc.1", "rc.01-v0.21.5", "rc.0-v0.21.5", + "rc.1-v2026.9.21", "v0.21.5", "abandoned-rc.1-v0.21.5"]) +def test_non_attempt_ref_shapes_are_rejected_as_archive_refs(ref): + from hermes_cli.release_channels import validate_request, ChannelError + with pytest.raises(ChannelError, match="(?i)archive ref"): + validate_request(stable_request(archive_ref=ref), policy="stable-release") diff --git a/tests/scripts/test_release_channels.py b/tests/scripts/test_release_channels.py index 8ef4529fc9..b0fce59861 100644 --- a/tests/scripts/test_release_channels.py +++ b/tests/scripts/test_release_channels.py @@ -580,3 +580,27 @@ def test_canary_native_version_is_derived_from_the_current_tag(): from scripts.releases.channel_releases import canary_windows_version assert canary_windows_version("v0.27.1+canary.20260829T010203Z") == "26.829.1.203" + + +def test_stable_requests_name_the_attempt_archive_only_when_given(): + from hermes_cli.release_channels import ChannelError + from scripts.releases.channels import preview_identity + with object_server() as (url, objects, headers, requests, faults): + pub = publisher(url) + identity = preview_identity("archived", "3" * 16) + gate = lambda request: True + + def allocate(commit, version, archive_ref): + return pub.allocate_protected( + "archived", commit, version, release_tag="v" + version, version=version, + windows_version=version + ".0", identity=identity, policy="stable-release", + release_gate=gate, archive_ref=archive_ref) + + request = allocate("a" * 40, "2.0.0", "rc.2-v2.0.0") + assert request["archiveRef"] == "rc.2-v2.0.0" + assert pub.request(request["buildId"]) == request + bare = allocate("b" * 40, "2.1.0", None) + assert "archiveRef" not in bare + assert pub.request(bare["buildId"]) == bare + with pytest.raises(ChannelError, match="(?i)archive ref"): + allocate("c" * 40, "2.2.0", "rc.2-v2.9.9")