blob: 9d5eb347304947ce82f7a3ddd0d580597ead1358 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Commit the working tree's changes through GitHub's `createCommitOnBranch`.
Why not `git commit` + `git push`: a commit made by a workflow with `git` is
unsigned, and a branch that requires signatures rejects it. Commits created
through this mutation are signed by GitHub itself and show as **Verified**,
with no key material anywhere in CI.
The mutation takes file *contents*, not a diff, so this collects the changed
paths from `git status --porcelain` and sends each one base64-encoded.
Deletions are sent as deletions. Nothing is staged and nothing is pushed —
the commit is created server-side on a branch that must already exist.
Usage:
gh-signed-commit.py --branch <name> --message-file <path> [--repo owner/name]
`--repo` defaults to `GITHUB_REPOSITORY`. The branch's current tip is read
back from the remote and sent as `expectedHeadOid`, so a concurrent push
makes the mutation fail rather than silently clobber.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
from pathlib import Path
#: The mutation. `fileChanges` carries whole file contents; GitHub computes the
#: tree, and signs the commit with its own key.
MUTATION = """
mutation ($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit {
oid
url
}
}
}
"""
class CommitError(RuntimeError):
"""Raised when the working tree or the arguments cannot produce a commit."""
def split_message(text: str) -> tuple[str, str]:
"""
Split a commit message into its headline and body.
The headline is the first line; the body is everything after the blank
line that follows it. A message with no body yields an empty body, which
the mutation accepts.
"""
stripped = text.strip("\n")
if not stripped.strip():
raise CommitError("commit message is empty")
headline, _, body = stripped.partition("\n")
return headline.strip(), body.lstrip("\n")
def collect_changes(root: Path, porcelain: str) -> tuple[list[str], list[str]]:
"""
Split `git status --porcelain=v1 -z` output into changed and deleted paths.
Renames are reported by git as a rename record carrying both paths; they
are decomposed into a deletion of the old path and an addition of the new
one, because the mutation has no rename concept.
"""
fields = [f for f in porcelain.split("\0") if f]
changed: list[str] = []
deleted: list[str] = []
index = 0
while index < len(fields):
entry = fields[index]
index += 1
if len(entry) < 4:
raise CommitError(f"unparsable git status entry: {entry!r}")
status, path = entry[:2], entry[3:]
if status[0] in ("R", "C"):
# A rename/copy record is followed by its source path.
if index >= len(fields):
raise CommitError(f"rename entry {entry!r} has no source path")
source = fields[index]
index += 1
if status[0] == "R":
deleted.append(source)
changed.append(path)
continue
if "D" in status:
deleted.append(path)
else:
changed.append(path)
return sorted(set(changed)), sorted(set(deleted))
def build_payload(
*,
repo: str,
branch: str,
expected_head_oid: str,
headline: str,
body: str,
additions: list[tuple[str, bytes]],
deletions: list[str],
) -> dict:
"""Build the `gh api graphql --input` document for the mutation."""
if not additions and not deletions:
raise CommitError("nothing to commit")
file_changes: dict[str, list[dict[str, str]]] = {}
if additions:
file_changes["additions"] = [
{"path": path, "contents": base64.b64encode(content).decode("ascii")}
for path, content in additions
]
if deletions:
file_changes["deletions"] = [{"path": path} for path in deletions]
return {
"query": MUTATION,
"variables": {
"input": {
"branch": {
"repositoryNameWithOwner": repo,
"branchName": branch,
},
"expectedHeadOid": expected_head_oid,
"message": {"headline": headline, "body": body},
"fileChanges": file_changes,
}
},
}
def _run(args: list[str], *, cwd: Path | None = None, stdin: str | None = None) -> str:
result = subprocess.run(args, cwd=cwd, input=stdin, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise CommitError(f"{' '.join(args)} failed: {result.stderr.strip()}")
return result.stdout
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--branch", required=True, help="branch to commit onto; must already exist")
parser.add_argument("--message-file", required=True, type=Path, help="commit message file")
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"), help="owner/name")
parser.add_argument(
"--expected-head-oid",
help=(
"commit the branch must still point at; the mutation fails if it has moved. "
"Defaults to whatever the branch points at right now, which is a weaker "
"guarantee — pass the sha the work was based on where you have it."
),
)
args = parser.parse_args(argv)
if not args.repo:
parser.error("--repo is required when GITHUB_REPOSITORY is unset")
root = Path(_run(["git", "rev-parse", "--show-toplevel"]).strip())
headline, body = split_message(args.message_file.read_text(encoding="utf-8"))
changed, deleted = collect_changes(root, _run(["git", "status", "--porcelain=v1", "-z"], cwd=root))
if not changed and not deleted:
print("gh-signed-commit: working tree is clean; nothing to commit")
return 0
additions = [(path, (root / path).read_bytes()) for path in changed]
head = args.expected_head_oid or _run(
["gh", "api", f"repos/{args.repo}/git/ref/heads/{args.branch}", "--jq", ".object.sha"]
)
payload = build_payload(
repo=args.repo,
branch=args.branch,
expected_head_oid=head.strip(),
headline=headline,
body=body,
additions=additions,
deletions=deleted,
)
out = _run(
["gh", "api", "graphql", "--input", "-", "--jq", ".data.createCommitOnBranch.commit.url"],
stdin=json.dumps(payload),
)
print(out.strip())
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except CommitError as exc:
print(f"gh-signed-commit: {exc}", file=sys.stderr)
sys.exit(1)