feat(release): tag completed channel and commit builds
This commit is contained in:
@@ -193,7 +193,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
parser.add_argument("--out", type=Path)
|
||||
parser.add_argument("--root", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
from scripts.releases.r2_scope import channel_public_base, require_run
|
||||
from scripts.releases.r2_scope import R2Scope, channel_public_base, require_run
|
||||
if args.disposable_run is not None:
|
||||
os.environ["R2_DISPOSABLE_RUN"] = require_run(args.disposable_run)
|
||||
args.public_base = channel_public_base(args.public_base)
|
||||
@@ -233,6 +233,15 @@ def main(argv: list[str] | None = None) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
result = publish(request, root, needs=needs, publisher=publisher)
|
||||
if args.command == "publish" and not R2Scope.configured().prefix:
|
||||
commit_build.publish_receipt(
|
||||
"channel", dict(os.environ), version=request["version"],
|
||||
commit=request["commit"], details={
|
||||
"buildId": request["buildId"],
|
||||
"channel": request["channel"],
|
||||
"requestSha256": args.request_sha256,
|
||||
},
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
if args.out:
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import tomllib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
WORKFLOW = "desktop-bundled-release.yml"
|
||||
@@ -22,34 +23,28 @@ def output(argv: list[str], repo: Path | None = None) -> str:
|
||||
return subprocess.check_output(argv, cwd=repo, text=True, encoding="utf-8", timeout=60).strip()
|
||||
|
||||
|
||||
def require_pushed(commit: str, remote: str, repo: Path | None = None) -> None:
|
||||
def require_pushed(commit: str, remote: str, repo: Path | None = None, *, run=output) -> None:
|
||||
"""Require ancestry from a branch or tag currently advertised by this remote."""
|
||||
require_commit(commit)
|
||||
advertised = {line.split()[0] for line in output(
|
||||
advertised = {line.split()[0] for line in run(
|
||||
["git", "ls-remote", remote, "refs/heads/*", "refs/tags/*"], repo).splitlines()}
|
||||
containing = set(output(["git", "for-each-ref", f"--contains={commit}", "--format=%(objectname)",
|
||||
f"refs/remotes/{remote}/", "refs/tags/"], repo).splitlines())
|
||||
containing = set(run(["git", "for-each-ref", f"--contains={commit}", "--format=%(objectname)",
|
||||
f"refs/remotes/{remote}/", "refs/tags/"], repo).splitlines())
|
||||
if not advertised.intersection(containing):
|
||||
raise ValueError(f"Commit {commit} is not reachable from a pushed branch or tag on {remote}")
|
||||
|
||||
|
||||
def version_at(repo: Path | None, commit: str) -> str:
|
||||
def version_at(repo: Path | None, commit: str, *, run=output) -> str:
|
||||
require_commit(commit)
|
||||
document = tomllib.loads(output(["git", "show", f"{commit}:pyproject.toml"], repo))
|
||||
document = tomllib.loads(run(["git", "show", f"{commit}:pyproject.toml"], repo))
|
||||
version = document["project"]["version"]
|
||||
if not isinstance(version, str) or not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version):
|
||||
raise ValueError("Commit packaging requires project.version=X.Y.Z")
|
||||
return version
|
||||
|
||||
|
||||
def admit(env: dict[str, str]) -> dict[str, str]:
|
||||
from scripts.releases.bundle_env import decode
|
||||
|
||||
commit = require_commit(env.get("BUILD_COMMIT", ""))
|
||||
if env.get("TAG") or env.get("RELEASE_PHASE") or env.get("UPLOAD_RELEASE", "false") != "false":
|
||||
raise ValueError("Commit builds cannot use tag, release-phase or upload_release")
|
||||
if env.get("TERMUX_UPGRADE_FROM_TAG"):
|
||||
raise ValueError("Commit builds do not run release-channel upgrade acceptance")
|
||||
def _controller(env: dict[str, str], commit: str, *, run=output,
|
||||
repo: Path | None = None) -> dict:
|
||||
default = env.get("DEFAULT_BRANCH", "")
|
||||
ref = f"refs/heads/{default}"
|
||||
repository = env.get("GITHUB_REPOSITORY", "")
|
||||
@@ -61,12 +56,120 @@ def admit(env: dict[str, str]) -> dict[str, str]:
|
||||
for actor in actors:
|
||||
if not actor:
|
||||
raise ValueError("Commit builds require a repository maintainer")
|
||||
permission = output(["gh", "api", f"repos/{repository}/collaborators/{actor}/permission", "--jq", ".permission"])
|
||||
permission = run(["gh", "api", f"repos/{repository}/collaborators/{actor}/permission",
|
||||
"--jq", ".permission"], repo)
|
||||
if permission not in {"write", "maintain", "admin"}:
|
||||
raise ValueError("Commit builds require repository write, maintain or admin permission")
|
||||
require_pushed(commit, "origin")
|
||||
require_pushed(commit, "origin", repo, run=run)
|
||||
return {"repository": repository, "default": default}
|
||||
|
||||
|
||||
def admit(env: dict[str, str], *, run=output, repo: Path | None = None) -> dict[str, str]:
|
||||
from scripts.releases.bundle_env import decode
|
||||
|
||||
commit = require_commit(env.get("BUILD_COMMIT", ""))
|
||||
if env.get("TAG") or env.get("RELEASE_PHASE") or env.get("UPLOAD_RELEASE", "false") != "false":
|
||||
raise ValueError("Commit builds cannot use tag, release-phase or upload_release")
|
||||
if env.get("TERMUX_UPGRADE_FROM_TAG"):
|
||||
raise ValueError("Commit builds do not run release-channel upgrade acceptance")
|
||||
_controller(env, commit, run=run, repo=repo)
|
||||
decode(env.get("BUNDLE_ENV_JSON", ""))
|
||||
return {"sha": commit, "channel": "commit", "payload-version": version_at(None, commit)}
|
||||
return {"sha": commit, "channel": "commit",
|
||||
"payload-version": version_at(repo, commit, run=run)}
|
||||
|
||||
|
||||
def receipt_tag(kind: str, version: str, created_at: str, run_id: str) -> str:
|
||||
"""Return the canonical post-build receipt identity."""
|
||||
from hermes_cli.update_channel import STABLE_TAG_RE
|
||||
|
||||
if kind not in {"channel", "commit"} or not STABLE_TAG_RE.fullmatch("v" + version):
|
||||
raise ValueError("Build receipt kind or version is invalid")
|
||||
if not re.fullmatch(r"[1-9][0-9]{0,19}", run_id):
|
||||
raise ValueError("Build receipt run ID is invalid")
|
||||
try:
|
||||
instant = datetime.fromisoformat(created_at.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except (AttributeError, ValueError) as error:
|
||||
raise ValueError("Build receipt creation time is invalid") from error
|
||||
if instant.microsecond or created_at != instant.strftime("%Y-%m-%dT%H:%M:%SZ"):
|
||||
raise ValueError("Build receipt creation time must be UTC whole seconds")
|
||||
return f"v{version}+{kind}.{instant.strftime('%Y%m%dT%H%M%SZ')}.{run_id}"
|
||||
|
||||
|
||||
def _verify_receipt(tag: str, commit: str, record: dict, *, run, repo: Path | None) -> None:
|
||||
tag_object = run(["git", "rev-parse", f"refs/tags/{tag}"], repo)
|
||||
if (run(["git", "cat-file", "-t", tag_object], repo) != "tag"
|
||||
or run(["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], repo) != commit
|
||||
or json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"], repo)) != record):
|
||||
raise ValueError("Build receipt tag differs from this run")
|
||||
|
||||
|
||||
def publish_receipt(kind: str, env: dict[str, str], *, version: str, commit: str,
|
||||
details: dict, run=output, repo: Path | None = None) -> dict:
|
||||
"""Create one annotated post-build receipt, or verify its exact replay."""
|
||||
require_commit(commit)
|
||||
controller = _controller(env, commit, run=run, repo=repo)
|
||||
run_id = env.get("GITHUB_RUN_ID", "")
|
||||
if env.get("GITHUB_ACTIONS") != "true" or not re.fullmatch(r"[1-9][0-9]{0,19}", run_id):
|
||||
raise ValueError("Build receipts require a GitHub Actions run ID")
|
||||
info = json.loads(run([
|
||||
"gh", "api", f"repos/{controller['repository']}/actions/runs/{run_id}",
|
||||
], repo))
|
||||
created_at = info.get("created_at")
|
||||
if (info.get("id") != int(run_id) or info.get("event") != "workflow_dispatch"
|
||||
or info.get("status") != "in_progress"
|
||||
or info.get("head_branch") != controller["default"]
|
||||
or info.get("head_sha") != env.get("GITHUB_SHA")
|
||||
or not isinstance(created_at, str)):
|
||||
raise ValueError("Build receipt run differs from the trusted controller")
|
||||
tag = receipt_tag(kind, version, created_at, run_id)
|
||||
record = {
|
||||
"schema": 1,
|
||||
"kind": kind,
|
||||
"tag": tag,
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"runId": run_id,
|
||||
"runCreatedAt": created_at,
|
||||
"details": details,
|
||||
}
|
||||
ref = f"refs/tags/{tag}"
|
||||
remote = {}
|
||||
for line in run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"], repo).splitlines():
|
||||
sha, name = line.split()
|
||||
remote[name] = sha
|
||||
if remote:
|
||||
if set(remote) != {ref, f"{ref}^{{}}"} or remote[f"{ref}^{{}}"] != commit:
|
||||
raise ValueError("Remote build receipt tag custody changed")
|
||||
run(["git", "fetch", "--force", "origin", f"+{ref}:{ref}"], repo)
|
||||
_verify_receipt(tag, commit, record, run=run, repo=repo)
|
||||
return record
|
||||
|
||||
try:
|
||||
local = run(["git", "rev-parse", "--verify", ref], repo)
|
||||
except subprocess.CalledProcessError:
|
||||
local = ""
|
||||
if local:
|
||||
_verify_receipt(tag, commit, record, run=run, repo=repo)
|
||||
else:
|
||||
message = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
run([
|
||||
"git", "-c", "user.name=Hermes Build Receipt",
|
||||
"-c", "user.email=actions@users.noreply.github.com",
|
||||
"tag", "-a", tag, commit, "-m", message,
|
||||
], repo)
|
||||
try:
|
||||
run(["git", "push", "origin", ref], repo)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
remote = {}
|
||||
for line in run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"], repo).splitlines():
|
||||
sha, name = line.split()
|
||||
remote[name] = sha
|
||||
if set(remote) != {ref, f"{ref}^{{}}"} or remote[f"{ref}^{{}}"] != commit:
|
||||
raise ValueError("Build receipt tag was not published exactly")
|
||||
run(["git", "fetch", "--force", "origin", f"+{ref}:{ref}"], repo)
|
||||
_verify_receipt(tag, commit, record, run=run, repo=repo)
|
||||
return record
|
||||
|
||||
|
||||
def resolve_revision(rev: str, remote: str, repo: Path) -> str:
|
||||
@@ -131,12 +234,26 @@ def cmd_build_commit(args) -> None:
|
||||
def main() -> None:
|
||||
import sys
|
||||
|
||||
if sys.argv[1:] != ["admit"]:
|
||||
raise SystemExit("usage: python -m scripts.releases.commit_build admit")
|
||||
values = admit(dict(os.environ))
|
||||
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
|
||||
stream.write("".join(f"{key}={value}\n" for key, value in values.items()))
|
||||
print(json.dumps(values, sort_keys=True))
|
||||
if sys.argv[1:] not in (["admit"], ["receipt"]):
|
||||
raise SystemExit("usage: python -m scripts.releases.commit_build {admit|receipt}")
|
||||
env = dict(os.environ)
|
||||
values = admit(env)
|
||||
if sys.argv[1:] == ["admit"]:
|
||||
with Path(env["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
|
||||
stream.write("".join(f"{key}={value}\n" for key, value in values.items()))
|
||||
print(json.dumps(values, sort_keys=True))
|
||||
return
|
||||
|
||||
from scripts.releases.bundle_env import decode
|
||||
from scripts.releases.stable import require_success
|
||||
|
||||
needs = json.loads(env.get("RELEASE_NEEDS", "{}"))
|
||||
require_success(needs, list(needs))
|
||||
result = publish_receipt(
|
||||
"commit", env, version=values["payload-version"], commit=values["sha"],
|
||||
details={"bundleEnv": decode(env.get("BUNDLE_ENV_JSON", ""))},
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user