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
64 changes: 64 additions & 0 deletions .github/scripts/test-dory-release-command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Offline regression tests for the single release-operator command."""

from __future__ import annotations

import pathlib
import subprocess
import unittest


ROOT = pathlib.Path(__file__).resolve().parents[2]
COMMAND = ROOT / "scripts" / "dory-release.sh"
LEGACY = ROOT / "scripts" / "publish-release.sh"


class DoryReleaseCommandTests(unittest.TestCase):
def test_command_is_shell_valid_and_documents_each_action(self) -> None:
subprocess.run(["bash", "-n", str(COMMAND)], cwd=ROOT, check=True)
result = subprocess.run(
[str(COMMAND), "--help"],
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout)
for action in ("check", "candidate", "status", "publish"):
self.assertIn(action, result.stdout)
self.assertIn("release-build/candidates/", result.stdout)
self.assertIn("physical qualification evidence", result.stdout)

def test_candidate_and_publish_share_exact_source_preflight(self) -> None:
source = COMMAND.read_text(encoding="utf-8")
self.assertEqual(source.count('verify_operator_context "$version"'), 2)
self.assertIn('CANDIDATE_WORKFLOW="release-candidate.yml"', source)
self.assertIn('PUBLIC_WORKFLOW="release.yml"', source)
self.assertIn('git branch --show-current', source)
self.assertIn('git status --porcelain --untracked-files=normal', source)
self.assertIn('local main must exactly match origin/main', source)
self.assertIn('.github/scripts/verify-release-identity.py', source)
self.assertIn('gh run watch "$RUN_ID"', source)
self.assertIn('.github/scripts/verify-public-release.py', source)

def test_private_candidate_is_downloaded_but_never_published(self) -> None:
source = COMMAND.read_text(encoding="utf-8")
candidate = source.split("stage_candidate()", 1)[1].split(
"publish_release()", 1
)[0]
self.assertIn("dory-signed-release-candidate-$HEAD_SHA-$run_attempt", candidate)
self.assertIn('gh run download "$RUN_ID"', candidate)
for forbidden in ("gh release", "verify-public-release.py", "PUBLIC_WORKFLOW"):
self.assertNotIn(forbidden, candidate)

def test_legacy_publisher_is_only_a_compatibility_shim(self) -> None:
subprocess.run(["bash", "-n", str(LEGACY)], cwd=ROOT, check=True)
source = LEGACY.read_text(encoding="utf-8")
self.assertIn('scripts/dory-release.sh" publish', source)
self.assertNotIn("gh workflow run", source)
self.assertNotIn("verify-release-identity.py", source)


if __name__ == "__main__":
unittest.main()
8 changes: 8 additions & 0 deletions .github/scripts/test-release-orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,18 @@ def test_source_is_shell_valid_and_binds_public_release_authority(self) -> None:
"scripts/generate-release-sbom.py",
"scripts/verify-release-sbom.py",
"scripts/generate-appcast.sh",
"scripts/build-dory-ffi-xcframework.sh",
"generated DoryFFI static library must contain arm64 and x86_64",
"write_release_manifest",
):
self.assertIn(contract, source)

ffi = source.index(" prepare_release_ffi_bridge\n")
renderer = source.index(" prepare_release_renderer_host\n")
archive = source.index(' archive_variant "$VARIANT" "$ARCHIVE"')
self.assertLess(ffi, renderer)
self.assertLess(renderer, archive)

def test_missing_metadata_fails_before_release_mutation(self) -> None:
result = subprocess.run(
[str(RELEASE)],
Expand Down
23 changes: 23 additions & 0 deletions .github/scripts/test-renderer-production-tuple.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,29 @@ def test_qualification_codesign_requirement_is_an_expression(self) -> None:
'=anchor apple generic and identifier "com.pythonxi.Dory.HVRunner"',
)

def test_qualification_accepts_parent_alias_but_rejects_symlink_bundle(self) -> None:
namespace = runpy.run_path(str(QUALIFICATION_VERIFIER))
direct_bundle_path = namespace["direct_bundle_path"]
verification_error = namespace["VerificationError"]
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
authority = root / "authority"
authority.mkdir()
runner = authority / "DoryHVRunner.app"
runner.mkdir()
alias = root / "alias"
alias.symlink_to(authority, target_is_directory=True)

self.assertEqual(
direct_bundle_path(alias / "DoryHVRunner.app"),
runner.resolve(strict=True),
)

indirect_runner = root / "Indirect.app"
indirect_runner.symlink_to(runner, target_is_directory=True)
with self.assertRaises(verification_error):
direct_bundle_path(indirect_runner)


if __name__ == "__main__":
unittest.main(verbosity=2)
24 changes: 14 additions & 10 deletions .github/scripts/verify-release-workflow-contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def expect_failure(callback, expected: str, message: str) -> None:
workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8")
pages_workflow = Path(".github/workflows/pages.yml").read_text(encoding="utf-8")
publisher = Path("scripts/publish-release.sh").read_text(encoding="utf-8")
release_command = Path("scripts/dory-release.sh").read_text(encoding="utf-8")
release_script = Path("scripts/release.sh").read_text(encoding="utf-8")
component_tests = Path("scripts/test-build-components.sh").read_text(encoding="utf-8")
identity_verifier_path = Path(".github/scripts/verify-release-identity.py")
Expand Down Expand Up @@ -652,18 +653,21 @@ def json_request(self, method, path):
"published appcast does not require component catalog schema 2",
)

require(publisher, 'gh workflow run "$WORKFLOW"', "publisher does not dispatch the release workflow")
require(publisher, "CURRENT_PROJECT_VERSION", "publisher does not parse the authoritative project build")
require(publisher, '--field "build=$PROJECT_BUILD"', "publisher does not dispatch the project build")
require(publisher, ".github/scripts/verify-release-identity.py", "publisher does not enforce complete-history monotonic identity")
require(publisher, 'scripts/dory-release.sh" publish', "legacy publisher does not delegate to the release command")
require(release_command, 'CANDIDATE_WORKFLOW="release-candidate.yml"', "release command cannot stage a private candidate")
require(release_command, 'PUBLIC_WORKFLOW="release.yml"', "release command cannot dispatch public publication")
require(release_command, "CURRENT_PROJECT_VERSION", "release command does not parse the authoritative project build")
require(release_command, '--field "build=$PROJECT_BUILD"', "release command does not dispatch the project build")
require(release_command, ".github/scripts/verify-release-identity.py", "release command does not enforce complete-history monotonic identity")
require(
publisher,
release_command,
"'^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$'",
"publisher accepts leading-zero semantic versions",
"release command accepts leading-zero semantic versions",
)
require(publisher, "case \"$tag_status\" in", "publisher treats every git lookup failure as tag absence")
require(publisher, "could not prove release v$VERSION is absent", "publisher treats ambiguous GitHub responses as absence")
require(publisher, 'gh run watch "$RUN_ID"', "publisher does not wait for the complete workflow")
require(publisher, ".github/scripts/verify-public-release.py", "publisher skips independent live verification")
require(release_command, "case \"$tag_status\" in", "release command treats every git lookup failure as tag absence")
require(release_command, "could not prove release v$version is absent", "release command treats ambiguous GitHub responses as absence")
require(release_command, 'gh run watch "$RUN_ID"', "release command does not wait for the selected workflow")
require(release_command, "dory-signed-release-candidate-$HEAD_SHA-$run_attempt", "release command cannot resolve the exact private candidate")
require(release_command, ".github/scripts/verify-public-release.py", "release command skips independent live verification")

print("release workflow contract: PASS")
1 change: 1 addition & 0 deletions .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ name: Stage Release Candidate
# component candidate. It deliberately does not create a tag, GitHub Release, appcast deployment,
# Homebrew update, signed component catalog, or support claim. Physical qualification consumes this
# immutable artifact before the normal Release workflow is allowed to publish it.
# Stage it only through: scripts/dory-release.sh candidate VERSION

on:
workflow_dispatch:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Release

# Builds, signs, notarizes, and publishes a Dory release on manual dispatch.
# Start public releases only through: scripts/publish-release.sh VERSION
# Start public releases only through: scripts/dory-release.sh publish VERSION
#
# Required repository secrets:
# DEVELOPER_ID_CERT_P12_BASE64 base64 of your "Developer ID Application" .p12
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
bash scripts/test-verify-macos-deployment-targets.sh
python3 .github/scripts/test-release-orchestrator.py
python3 .github/scripts/test-release-candidate-workflow.py
python3 .github/scripts/test-dory-release-command.py
bash scripts/test-dmg-distribution-signing.sh
python3 .github/scripts/test-ci-umbrella.py
python3 .github/scripts/test-interrupted-upgrade-rollback-gate.py
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,12 @@ UI tests, gvproxy, and a compile-only app build. Public CLI/repository contracts
live-engine, network, filesystem, migration, compatibility, performance, endurance, and notarization
gates against the exact candidate.

Release operators use one command surface: [`scripts/dory-release.sh`](scripts/dory-release.sh).
Its clearly separated `check`, `candidate`, `status`, and `publish` actions coordinate the private
signed candidate and the qualification-gated GitHub, Pages, component-catalog, and Homebrew release.
See the [release guide](docs/releasing.md); the lower-level release scripts and workflows are
implementation details, not separate operator steps.

| Path | Contents |
|---|---|
| `Dory/` | Native SwiftUI app and runtime integration |
Expand Down
34 changes: 34 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Releasing Dory

`scripts/dory-release.sh` is the only operator-facing release command. Do not manually dispatch
release workflows or call the build, qualification, catalog, Pages, or Homebrew implementation
scripts. Keeping those pieces independently testable is useful; making a release operator
coordinate them is not.

## Actions

| Command | One responsibility | Public mutation |
|---|---|---|
| `scripts/dory-release.sh check VERSION` | Prove clean exact `main`, version/build identity, workflow contract, and release absence | No |
| `scripts/dory-release.sh candidate VERSION` | Build, sign, notarize, staple, verify, and download one private modular candidate | No |
| `scripts/dory-release.sh status [RUN_ID]` | Show the current candidate/publication state | No |
| `scripts/dory-release.sh publish VERSION` | Run qualification-gated GitHub, Pages/appcast/catalog, and Homebrew publication and verify the live result | Yes |

Candidate staging waits by default and downloads the exact artifact beneath
`release-build/candidates/`. Use `--no-wait` only when another operator will monitor the printed run
URL. A candidate never creates a tag, GitHub Release, appcast, Pages catalog, or Homebrew update.

Public publication also waits by default. It cannot bypass physical qualification or publish a
candidate built from a different commit. If a code change lands after candidate staging, stage and
qualify a new candidate from the new exact `main` commit.

## Internal boundaries

- `.github/workflows/release-candidate.yml` produces private immutable candidate bytes.
- `.github/workflows/release.yml` owns qualification-gated public mutation.
- `scripts/release.sh` is the internal macOS asset builder used by those workflows.
- `scripts/qualify-release-candidate.sh` and the evidence verifiers implement the physical gate.
- `scripts/publish-release.sh` remains only as a compatibility shim for older automation.

These are implementation details. New release procedures and documentation should call only
`scripts/dory-release.sh`.
Loading
Loading