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
52 changes: 52 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
81 changes: 57 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,40 +1,53 @@
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
ci/check_release_process_substrate.py

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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -82,7 +99,6 @@ jobs:

symbolizer-workers:
needs: release-guard
if: startsWith(github.event.release.tag_name, 'v')
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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}')"
Expand All @@ -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:
Expand All @@ -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")"
Expand All @@ -212,18 +230,33 @@ jobs:
fi

release-assets:
if: ${{ !inputs.dry_run }}
needs: [validate-and-package, symbolizer-workers]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
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
74 changes: 74 additions & 0 deletions ci/auto_release.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading