-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommit-branch.sh
More file actions
executable file
·181 lines (165 loc) · 7.36 KB
/
Copy pathcommit-branch.sh
File metadata and controls
executable file
·181 lines (165 loc) · 7.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
#!/usr/bin/env bash
#
# Commit a working tree to a branch through GitHub's API, so the commit is
# signed.
#
# commit-branch.sh <workdir> <branch> <message> [pathspec ...]
#
# A pathspec limits the commit to part of the tree. Without one the whole
# working tree is committed, which is what a state branch wants; with one, only
# what a build step is supposed to have produced, so a stray file written
# elsewhere cannot ride along unnoticed.
#
# A `git commit` in CI is unsigned unless a private key sits on the runner,
# which is not a trade worth making for a state branch. createCommitOnBranch
# builds the commit server-side and GitHub signs it, so the history is
# verifiable without a key ever leaving a person's machine. The author is
# whoever the token is -- github-actions[bot] under ${{ github.token }} --
# rather than an identity invented in a workflow file.
#
# The mutation takes explicit additions and deletions rather than a tree, so
# the diff is computed here. A missed deletion would leave a file on the branch
# forever, which is why the file set comes from git rather than a directory
# walk.
#
# expectedHeadOid makes the write conditional on the branch not having moved.
# The ingest is serialised by its concurrency group, so a mismatch means
# something genuinely unexpected happened and the run should fail.
set -euo pipefail
# Without this, set -e stops at the edge of a command substitution: a function
# called as x="$(f)" keeps running after a failure instead of aborting.
shopt -s inherit_errexit
USAGE="usage: commit-branch.sh <workdir> <branch> <message> [pathspec ...]"
WORKDIR="${1:?$USAGE}"
BRANCH="${2:?$USAGE}"
MESSAGE="${3:?$USAGE}"
shift 3
PATHSPEC=("$@")
: "${GITHUB_TOKEN:?a token with contents:write}"
: "${GITHUB_REPOSITORY:?owner/repo}"
API="${GITHUB_API_URL:-https://api.github.com}"
log() { printf '%s\n' "$*" >&2; }
cd "$WORKDIR"
# createCommitOnBranch commits onto a branch that already exists; it cannot
# create one, because a branch needs a commit and this is how commits are made.
# Bootstrapping a missing branch is deliberately not done here: this fires only
# if a state branch is deleted, and the fix then is to recreate it deliberately
# rather than have a workflow guess at its contents.
if ! git rev-parse --git-dir >/dev/null 2>&1; then
log "FATAL: $WORKDIR is not a checkout of $BRANCH."
log " The branch has to exist before anything can be committed to it."
exit 1
fi
# Stage so git decides what changed, not a directory walk. An empty PATHSPEC
# expands to nothing, which after -- means exactly what passing no pathspec
# means; both diffs below already rely on that.
git add -A -- "${PATHSPEC[@]}"
# The oid of the commit this makes, for a caller that has to name it. Without
# it a caller can only ask GitHub to resolve the branch by name afterwards, and
# that is a race: a workflow dispatched seconds after this mutation can still
# resolve the branch to the PREVIOUS tip and then act on the wrong tree.
# pkghaus/packages lost an ouch release to exactly that on 2026-09-13.
#
# Empty when nothing was committed, so a caller can tell the two apart rather
# than inferring it from a tip that did not move. Silent when GITHUB_OUTPUT is
# unset, which is every local run and the whole test suite.
emit_sha() { # oid
[ -n "${GITHUB_OUTPUT:-}" ] || return 0
printf 'sha=%s\n' "$1" >> "$GITHUB_OUTPUT"
}
if git diff --cached --quiet HEAD -- "${PATHSPEC[@]}"; then
log "nothing changed on $BRANCH"
emit_sha ""
exit 0
fi
export BRANCH MESSAGE GITHUB_REPOSITORY
HEAD_OID="$(git rev-parse HEAD)"
export HEAD_OID
payload="$(mktemp)"
response="$(mktemp)"
trap 'rm -f "$payload" "$response"' EXIT
# -z output and NUL parsing: a path may contain anything but NUL, and git
# quotes unusual ones in its default format. Reading raw never unquotes.
# --no-renames: the mutation has no concept of a rename, and the add/delete
# pair it wants is exactly what this produces.
# The Python below is quoted so the shell leaves it alone; it reads what it
# needs from the environment.
# shellcheck disable=SC2016
git diff --cached -z --no-renames --name-status HEAD -- "${PATHSPEC[@]}" | python3 -c '
import base64, json, os, sys
raw = sys.stdin.buffer.read().split(b"\0")
additions, deletions = [], []
i = 0
while i + 1 < len(raw):
status = raw[i].decode()
if not status:
break
path = raw[i + 1].decode()
if status[0] == "D":
deletions.append({"path": path})
else:
with open(path, "rb") as fh:
additions.append({"path": path,
"contents": base64.b64encode(fh.read()).decode()})
i += 2
json.dump({
"query": "mutation($input: CreateCommitOnBranchInput!) {"
" createCommitOnBranch(input: $input) {"
" commit { oid signature { isValid state } } } }",
"variables": {"input": {
"branch": {"repositoryNameWithOwner": os.environ["GITHUB_REPOSITORY"],
"branchName": os.environ["BRANCH"]},
"message": {"headline": os.environ["MESSAGE"]},
"expectedHeadOid": os.environ["HEAD_OID"],
"fileChanges": {"additions": additions, "deletions": deletions},
}},
}, sys.stdout)
sys.stderr.write(f"{len(additions)} addition(s), {len(deletions)} deletion(s)\n")
' > "$payload"
log "committing to $BRANCH, $(wc -c < "$payload") byte payload"
# --fail-with-body writes the body and exits non-zero, and under set -e that
# exit skipped the reporting below and the trap deleted the file: an auth
# failure or a 502 showed curl's generic "returned error: NNN" and nothing
# GitHub actually said. The body is printed here instead, where it is still on
# disk.
#
# --retry covers the transient half. This runs after the archive has already
# been published to R2, so failing here leaves the bucket ahead of the state
# branches until the next ingest repairs it. A retry is safe rather than
# merely convenient: expectedHeadOid makes the mutation conditional, so if the
# first attempt did land, the retry is refused for the right reason instead of
# committing twice.
if ! curl -sS --fail-with-body -X POST "$API/graphql" \
--max-time 120 --retry 3 --retry-connrefused --retry-all-errors \
-H "Authorization: bearer $GITHUB_TOKEN" \
-H 'Content-Type: application/json' \
--data @"$payload" > "$response"; then
log "FATAL: the GraphQL request to $API failed. What it returned:"
cat "$response" >&2
exit 1
fi
# stdout carries the oid and nothing else, so the substitution below captures
# it; every diagnostic goes to stderr as before.
#
# What keeps an unsigned commit from reaching the caller is the exit status,
# not the order of the prints: a non-zero exit here makes the assignment fail,
# and set -e stops the script before emit_sha runs. Printing the oid after the
# signature check is belt-and-braces on top of that, which is why moving it
# earlier does not break the tests.
oid="$(python3 - "$response" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
if d.get("errors"):
print("FATAL: " + "; ".join(e.get("message", "?") for e in d["errors"]), file=sys.stderr)
raise SystemExit(1)
c = d["data"]["createCommitOnBranch"]["commit"]
sig = c.get("signature") or {}
print(f'committed {c["oid"][:12]} signature={sig.get("state")} valid={sig.get("isValid")}',
file=sys.stderr)
if not sig.get("isValid"):
print("FATAL: GitHub did not sign the commit", file=sys.stderr)
raise SystemExit(1)
print(c["oid"])
PY
)"
emit_sha "$oid"