diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 00000000..122f1c1f --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,52 @@ +name: Auto-Release + +on: + push: + branches: [main] + paths: [Cargo.toml] + workflow_dispatch: + inputs: + dry_run: + description: Verify and upload build artifacts without publishing anything + type: boolean + default: true + +permissions: + contents: write + actions: read + +concurrency: + group: kernal-api-release + cancel-in-progress: false + +jobs: + prepare: + # Existing-tag dispatch is the immutable registry recovery path after main advances. + if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + should_release: ${{ steps.detect.outputs.should_release }} + tag: ${{ steps.detect.outputs.tag }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 + - name: Detect version bump + id: detect + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BEFORE: ${{ github.event.before }} + run: uv run --no-project --python 3.13 ci/auto_release.py + + release: + needs: prepare + if: needs.prepare.outputs.should_release == 'true' + uses: ./.github/workflows/release.yml + with: + source_sha: ${{ github.sha }} + tag: ${{ needs.prepare.outputs.tag }} + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 889df851..9e8df187 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,6 +309,7 @@ jobs: - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 - run: uv run --no-project ci/check_compilation_boundary_dependencies.py - run: uv run --no-project ci/test_release_package_features.py + - run: uv run --no-project --python 3.13 ci/test_auto_release.py supported-targets: strategy: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93d90ddd..2ef14c1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,26 +1,40 @@ name: Release on: - release: - types: [published] + workflow_call: + inputs: + source_sha: + required: true + type: string + tag: + required: true + type: string + dry_run: + required: true + type: boolean permissions: contents: write - actions: write + actions: read env: SOURCE_DATE_EPOCH: "0" jobs: release-guard: - if: startsWith(github.event.release.tag_name, 'v') runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ inputs.source_sha }} lfs: true + fetch-depth: 0 - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 + - name: Verify immutable release source and tag + env: + RELEASE_SHA: ${{ inputs.source_sha }} + RELEASE_TAG: ${{ inputs.tag }} + run: uv run --no-project --python 3.13 ci/auto_release.py --verify-source - name: Reject migration-only running-process paths run: >- uv run --no-project --with tomli==2.2.1 python @@ -28,13 +42,12 @@ jobs: validate-and-package: needs: release-guard - if: startsWith(github.event.release.tag_name, 'v') runs-on: ubuntu-latest environment: release steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ inputs.source_sha }} lfs: true - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 - uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 @@ -49,7 +62,7 @@ jobs: shell: bash run: | manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)" - test "v${manifest_version}" = "${{ github.event.release.tag_name }}" + test "v${manifest_version}" = "${{ inputs.tag }}" test "${manifest_version}" != "0.0.0" grep -q "version = \"${manifest_version}\"" pyproject.toml grep -q "__version__ = \"${manifest_version}\"" python/kernal_api/__init__.py @@ -67,12 +80,16 @@ jobs: - name: Package exact Python companion run: uv build --clear - run: uv run --no-project --with twine twine check dist/* + - name: Stage flat registry artifacts + shell: bash + run: | + set -euo pipefail + mkdir registry-packages + cp target/package/kernal-api-*.crate dist/* registry-packages/ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: registry-packages - path: | - target/package/kernal-api-*.crate - dist/* + path: registry-packages/* if-no-files-found: error - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -82,7 +99,6 @@ jobs: symbolizer-workers: needs: release-guard - if: startsWith(github.event.release.tag_name, 'v') strategy: fail-fast: false matrix: @@ -115,7 +131,7 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ inputs.source_sha }} - if: ${{ !matrix.cross }} uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 - if: ${{ matrix.cross }} @@ -142,13 +158,14 @@ jobs: if-no-files-found: error publish-crates: - needs: [release-guard, validate-and-package] + if: ${{ !inputs.dry_run && vars.PUBLISH_CRATES_IO == 'true' }} + needs: [release-guard, validate-and-package, release-assets] runs-on: ubuntu-latest environment: release steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ inputs.source_sha }} lfs: true - uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -160,7 +177,7 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | - version="${{ github.event.release.tag_name }}" + version="${{ inputs.tag }}" version="${version#v}" crate_file="$(find release-packages -name "kernal-api-${version}.crate" -print -quit)" local_sha="$(sha256sum "${crate_file}" | awk '{print $1}')" @@ -178,7 +195,8 @@ jobs: fi publish-pypi: - needs: validate-and-package + if: ${{ !inputs.dry_run && vars.PUBLISH_PYPI == 'true' }} + needs: [validate-and-package, release-assets] runs-on: ubuntu-latest environment: release steps: @@ -193,7 +211,7 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | - version="${{ github.event.release.tag_name }}" + version="${{ inputs.tag }}" version="${version#v}" status="$(curl -sS -o pypi.json -w '%{http_code}' \ "https://pypi.org/pypi/kernal-api/${version}/json")" @@ -212,6 +230,7 @@ jobs: fi release-assets: + if: ${{ !inputs.dry_run }} needs: [validate-and-package, symbolizer-workers] runs-on: ubuntu-latest steps: @@ -219,11 +238,25 @@ jobs: with: path: release-assets merge-multiple: true - - name: Attach verified sidecars and isolated workers + - name: Create or verify GitHub release assets env: GH_TOKEN: ${{ github.token }} - run: >- - gh release upload "${{ github.event.release.tag_name }}" - release-assets/conpty-sidecar-*.tar.zst - release-assets/kernal-symbolize-* - --repo "${{ github.repository }}" --clobber + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_SHA: ${{ inputs.source_sha }} + shell: bash + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate --slurp > releases.json + existing="$(jq --arg tag "$RELEASE_TAG" '[.[][] | select(.tag_name == $tag)] | length' releases.json)" + if [[ "$existing" == 0 ]]; then + gh release create "$RELEASE_TAG" release-assets/* \ + --repo "$GITHUB_REPOSITORY" --target "$RELEASE_SHA" --generate-notes + else + # Manual registry recovery must not overwrite published GitHub assets. + jq -e --arg tag "$RELEASE_TAG" '.[][] | select(.tag_name == $tag) | .draft == false' releases.json + download_dir="$(mktemp -d)" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir "$download_dir" + for asset in release-assets/*; do + cmp "$asset" "$download_dir/$(basename "$asset")" + done + fi diff --git a/ci/auto_release.py b/ci/auto_release.py new file mode 100644 index 00000000..f7f5d121 --- /dev/null +++ b/ci/auto_release.py @@ -0,0 +1,74 @@ +"""Detect a main-branch version bump; fail closed on Git/API errors.""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +import tomllib + + +def release_tag(manifest: str) -> str: + version = tomllib.loads(manifest)["package"]["version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version): + raise ValueError("release version must be a supported semantic version") + if version == "0.0.0": + raise ValueError("the namespace reservation must never be released") + return f"v{version}" + + +def should_release(tag: str, previous_tag: str | None, manual: bool) -> bool: + return manual or (previous_tag is not None and tag != previous_tag) + + +def main() -> None: + tag = release_tag(Path("Cargo.toml").read_text()) + if sys.argv[1:] == ["--verify-source"]: + if os.environ["RELEASE_TAG"] != tag: + raise ValueError("release tag does not match Cargo.toml") + head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + if head != os.environ["GITHUB_SHA"] or head != os.environ["RELEASE_SHA"]: + raise ValueError("release source must be the workflow commit") + tags = subprocess.check_output(["git", "tag", "--list", tag], text=True) + if tags.strip(): + tagged = subprocess.check_output( + ["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], text=True + ).strip() + if tagged != head: + raise ValueError("existing release tag points to a different commit") + return + if sys.argv[1:]: + raise ValueError("unexpected command arguments") + manual = os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" + previous_tag = None + if not manual: + before = os.environ["RELEASE_BEFORE"] + if not re.fullmatch(r"[0-9a-f]{40}", before) or set(before) == {"0"}: + raise ValueError("automatic release requires a valid previous commit") + previous = subprocess.check_output( + ["git", "show", f"{before}:Cargo.toml"], text=True + ) + # The first usable release may follow the namespace reservation. + previous_tag = "v" + tomllib.loads(previous)["package"]["version"] + proceed = should_release(tag, previous_tag, manual) + if proceed and not manual: + existing = subprocess.check_output( + [ + "gh", + "api", + f"repos/{os.environ['GITHUB_REPOSITORY']}/releases", + "--paginate", + "--jq", + ".[].tag_name", + ], + text=True, + ).splitlines() + proceed = tag not in existing + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"tag={tag}\nshould_release={str(proceed).lower()}\n") + print(f"{tag}: {'verify release' if proceed else 'no new release'}") + + +if __name__ == "__main__": + main() diff --git a/ci/test_auto_release.py b/ci/test_auto_release.py new file mode 100644 index 00000000..bb6200b3 --- /dev/null +++ b/ci/test_auto_release.py @@ -0,0 +1,75 @@ +"""Contract checks for autonomous release orchestration.""" + +import unittest +from pathlib import Path +from unittest.mock import patch + +from auto_release import main, release_tag, should_release + +ROOT = Path(__file__).resolve().parents[1] + + +class AutoReleaseTests(unittest.TestCase): + def test_version_detection(self): + self.assertEqual(release_tag('[package]\nversion = "0.1.0"'), "v0.1.0") + for version in ["0.0.0", "bad", "0.1.0\nmalicious"]: + with self.assertRaises(ValueError): + release_tag(f'[package]\nversion = "{version}"') + self.assertTrue(should_release("v0.1.0", "v0.0.0", False)) + self.assertFalse(should_release("v0.1.0", "v0.1.0", False)) + self.assertFalse(should_release("v0.1.0", None, False)) + self.assertTrue(should_release("v0.1.0", "v0.1.0", True)) + + def test_workflow_has_safe_automatic_and_manual_entrypoints(self): + workflow = (ROOT / ".github/workflows/auto-release.yml").read_text() + self.assertIn("branches: [main]", workflow) + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("default: true", workflow) + self.assertIn("cancel-in-progress: false", workflow) + self.assertIn("uses: ./.github/workflows/release.yml", workflow) + self.assertIn("startsWith(github.ref, 'refs/tags/v')", workflow) + + def test_registry_publish_is_explicitly_opt_in(self): + workflow = (ROOT / ".github/workflows/release.yml").read_text() + self.assertIn("workflow_call:", workflow) + self.assertNotIn("github.event.release.tag_name", workflow) + self.assertIn("vars.PUBLISH_CRATES_IO == 'true'", workflow) + self.assertIn("vars.PUBLISH_PYPI == 'true'", workflow) + self.assertIn( + "needs: [release-guard, validate-and-package, release-assets]", workflow + ) + self.assertIn("needs: [validate-and-package, release-assets]", workflow) + self.assertIn("!inputs.dry_run", workflow) + self.assertNotIn("--clobber", workflow) + self.assertIn("path: registry-packages/*", workflow) + self.assertIn( + "cp target/package/kernal-api-*.crate dist/* registry-packages/", workflow + ) + + def verify_source(self, tag="v0.1.0", sha="a" * 40, tagged_sha=None): + env = {"RELEASE_TAG": tag, "RELEASE_SHA": sha, "GITHUB_SHA": "a" * 40} + results = ["a" * 40, "" if tagged_sha is None else tag, tagged_sha] + with ( + patch("sys.argv", ["auto_release.py", "--verify-source"]), + patch.dict("os.environ", env), + patch.object(Path, "read_text", return_value='[package]\nversion="0.1.0"'), + patch("subprocess.check_output", side_effect=results), + ): + main() + + def test_source_guard_accepts_absent_or_matching_tag(self): + self.verify_source() + self.verify_source(tagged_sha="a" * 40) + + def test_source_guard_rejects_version_commit_and_existing_tag_mismatch(self): + for arguments in [ + {"tag": "v0.2.0"}, + {"sha": "b" * 40}, + {"tagged_sha": "b" * 40}, + ]: + with self.subTest(arguments=arguments), self.assertRaises(ValueError): + self.verify_source(**arguments) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/registry-release-readiness.md b/docs/registry-release-readiness.md index 867ff892..34a0b7b8 100644 --- a/docs/registry-release-readiness.md +++ b/docs/registry-release-readiness.md @@ -46,8 +46,32 @@ and GREEN afterward. This gate detects the problem; it does not fix the underlying registry viewer graph. Resolve the patched dependencies and rerun package verification plus native isolation checks before publishing. -Publication credentials also need configuration. Repository Actions secrets -and environments were empty when inspected; no token was present in the -current environment or default Cargo credentials file. The existing workflow -expects `CARGO_REGISTRY_TOKEN` and `PYPI_API_TOKEN`. Never put tokens in source, -issue comments, logs, or chat. +## Autonomous releases + +`.github/workflows/auto-release.yml` follows the Soldr/zccache version-bump +pattern. A push to `main` that changes the Cargo package version starts the +existing package and six-target symbolizer verification pipeline. Unchanged +versions and existing GitHub releases do not automatically publish again. +Manual dispatch on `main` defaults to `dry_run: true`: verification and Actions +artifacts only, with no tag, GitHub release, or registry writes. Set it to false +to release the current version without another version bump. Existing tags must +resolve to the exact workflow commit; tags and release assets are never replaced. +GitHub release creation happens directly in the same workflow, not through a +second workflow triggered by a bot-created release event. + +Registry publishing is deferred by owner request. GitHub packages, sidecars and +workers can release without registry credentials. To enable crates.io later, +configure `CARGO_REGISTRY_TOKEN` and set the repository Actions variable `PUBLISH_CRATES_IO` +to `true`. PyPI independently uses `PYPI_API_TOKEN` and `PUBLISH_PYPI=true`. +The publishing jobs use the `release` environment; secrets can be configured +there or at repository scope. Opt-in variables must be repository-level because +job conditions are evaluated before entering the environment. Neither variable +is set by this change. +Skipping an opted-out registry is not evidence that its package is published. +Manual non-dry-run dispatch can recover registry publication for an existing +GitHub release only from the same tagged commit and with identical rebuilt +GitHub assets. A mismatch fails rather than overwriting an immutable release. +After `main` advances, dispatch on the existing tag instead: +`gh workflow run auto-release.yml --ref v0.1.0 -f dry_run=false`. +Registry jobs wait for successful GitHub asset comparison before publishing. +Never put tokens in source, issue comments, logs, or chat. diff --git a/tests/facade_policy.rs b/tests/facade_policy.rs index e0c4666c..7b36c0bc 100644 --- a/tests/facade_policy.rs +++ b/tests/facade_policy.rs @@ -118,7 +118,7 @@ fn process_substrate_is_exact_feature_minimal_and_private() { } assert!( workflow_job(&release_workflow, "publish-crates") - .contains("needs: [release-guard, validate-and-package]"), + .contains("needs: [release-guard, validate-and-package, release-assets]"), "publish-crates must directly depend on release-guard before cargo publish" );