实测的缺陷:v1.0.2 的二进制是对的(校验和与b060f86干净树上的本地构建逐字节一致),但 **tag 落在 1.0.1 那个提交上**——因为脚本不自己建 tag,而是让 Gitea 建,而 Gitea 把 release 的 tag 建在"创建那一刻远端默认分支的 HEAD"上。当时b060f86还没 push,origin/main 还是 f499d02, 于是 v1.0.1 和 v1.0.2 指向同一个树,去读该 release 源码的人拿到的是修复前的版本。 两处修正: 1. 创建 release 时显式给 `target_commitish`,不再让服务器决定。 2. 发布前要求工作树干净、且 HEAD 已在某个远端分支上。前者不只是洁癖:Go 默认把 VCS 信息打进 二进制,工作树脏时构建产物带 "modified" 标记——实测同一个提交,干净树是 9c9c09dd…,脏树是 1d9bf3a1…,所以"tag 必须指向二进制所来自的那个提交"这句话在有未提交改动时根本不成立。
192 lines
8.8 KiB
Bash
Executable File
192 lines
8.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Publishes this agent's cross-compiled binaries as a Gitea release.
|
|
#
|
|
# The release is what makes the install command real: `msa-agent enroll` runs on the node, and the node
|
|
# has no Go toolchain, so the binary has to come from somewhere. Before this the only way to get one was
|
|
# to build it on another machine and scp it, which is a step the install command cannot express.
|
|
#
|
|
# The token is read from the git credential helper rather than an environment variable, so it is not in
|
|
# the process table and not in a shell history. Override with MSA_GITEA_TOKEN when running somewhere
|
|
# that has no helper.
|
|
#
|
|
# Usage:
|
|
# ./scripts/release.sh # publish the version in the Makefile
|
|
# ./scripts/release.sh --tag v1.0.0 # publish an explicit tag
|
|
# ./scripts/release.sh --dry-run # build, checksum, print, upload nothing
|
|
#
|
|
# This repository must stay publicly readable: the install command fetches the binary from a node that
|
|
# has no credentials, and Gitea answers 404 for a private repository, so the failure on the node looks
|
|
# like a wrong URL. The script checks that and refuses rather than publishing something unreachable.
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$ROOT"
|
|
|
|
REMOTE="$(git config --get remote.origin.url)"
|
|
HOST="$(printf '%s' "$REMOTE" | sed -E 's#^https?://([^/]+)/.*#\1#')"
|
|
SLUG="$(printf '%s' "$REMOTE" | sed -E 's#^https?://[^/]+/##; s#\.git$##')"
|
|
if [[ -z "$HOST" || -z "$SLUG" ]]; then
|
|
echo "release: cannot parse the origin remote ($REMOTE)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
VERSION="$(sed -n 's/^VERSION[[:space:]]*?*=[[:space:]]*//p' Makefile | head -1)"
|
|
TAG="v${VERSION}"
|
|
DRY_RUN=0
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--tag) TAG="$2"; shift 2 ;;
|
|
--dry-run) DRY_RUN=1; shift ;;
|
|
*) echo "release: unknown argument $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
step() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
|
|
|
|
# The commit the tag must point at, and the check that it is published.
|
|
#
|
|
# This is not tidiness. Gitea creates the tag for a release at whatever the remote's default-branch HEAD
|
|
# is *at the moment the release is created* — and this script does not create tags itself. So publishing
|
|
# before pushing puts the tag on the previous commit. Measured: v1.0.2 was published with the 1.0.2
|
|
# binaries (the checksums match a local build of the right commit) but its tag landed on the 1.0.1
|
|
# commit, because that was still origin/main; `v1.0.1` and `v1.0.2` ended up naming the same tree, and
|
|
# anyone reading the release's source would get the version before the fix.
|
|
#
|
|
# So the target is passed explicitly below, and a release whose commit is not pushed is refused: a tag
|
|
# that exists only locally would be created against whatever the remote has, which is the same defect
|
|
# with a different cause.
|
|
HEAD_SHA="$(git rev-parse HEAD)"
|
|
if [[ "$DRY_RUN" -eq 0 ]]; then
|
|
if [[ -n "$(git status --porcelain)" ]]; then
|
|
echo "release: the working tree has uncommitted changes; commit them first. The tag must name the" >&2
|
|
echo " commit the binaries were built from, and an uncommitted build names nothing." >&2
|
|
exit 1
|
|
fi
|
|
if ! git branch -r --contains "$HEAD_SHA" 2>/dev/null | grep -q .; then
|
|
echo "release: ${HEAD_SHA} is not on any remote branch. Push first: Gitea creates the tag at the" >&2
|
|
echo " remote's default-branch HEAD, so publishing now would tag the previous commit." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
echo "tag ${TAG} will point at ${HEAD_SHA} ($(git log --oneline -1 "$HEAD_SHA" | cut -d' ' -f2-))"
|
|
|
|
step "building ${TAG}"
|
|
# VERSION is passed through so the binary reports the tag it was published under, which is what an
|
|
# operator sees in `msa-agent version` and in the agent's startup log line.
|
|
make build-arm64 build-armv7 VERSION="${VERSION}"
|
|
|
|
step "checksums"
|
|
(
|
|
cd dist
|
|
shasum -a 256 msa-agent-linux-arm64 msa-agent-linux-armv7 > SHA256SUMS
|
|
cat SHA256SUMS
|
|
)
|
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
step "dry run: nothing was uploaded"
|
|
exit 0
|
|
fi
|
|
|
|
TOKEN="${MSA_GITEA_TOKEN:-}"
|
|
if [[ -z "$TOKEN" ]]; then
|
|
# The credential helper is what `git push` already uses, so there is no second secret to keep.
|
|
TOKEN="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill 2>/dev/null | sed -n 's/^password=//p')"
|
|
fi
|
|
if [[ -z "$TOKEN" ]]; then
|
|
echo "release: no API token. Set MSA_GITEA_TOKEN, or make sure git can authenticate to $HOST." >&2
|
|
exit 1
|
|
fi
|
|
|
|
API="https://${HOST}/api/v1/repos/${SLUG}"
|
|
AUTH=(-H "Authorization: token ${TOKEN}")
|
|
|
|
step "target repository"
|
|
PRIVATE="$(curl -sf "${AUTH[@]}" "${API}" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("private"))' 2>/dev/null || echo unknown)"
|
|
echo "${SLUG} (private: ${PRIVATE})"
|
|
if [[ "$PRIVATE" != "False" ]]; then
|
|
echo "release: ${SLUG} is not publicly readable. A node fetching the binary has no credentials and" >&2
|
|
echo " would get 404, which looks like a wrong URL. Make it public before publishing." >&2
|
|
exit 1
|
|
fi
|
|
|
|
step "creating the release ${TAG}"
|
|
# A release that already exists is reused rather than refused: re-running this after a fix should
|
|
# replace the binaries, and failing here would mean the operator has to delete the release by hand.
|
|
EXISTING="$(curl -sf "${AUTH[@]}" "${API}/releases/tags/${TAG}" 2>/dev/null || true)"
|
|
if [[ -n "$EXISTING" ]]; then
|
|
RELEASE_ID="$(printf '%s' "$EXISTING" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
|
echo "reusing release ${TAG} (id ${RELEASE_ID})"
|
|
else
|
|
BODY="$(python3 - "$TAG" "$VERSION" "$HEAD_SHA" <<'PY'
|
|
import json, sys
|
|
tag, version, head = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
print(json.dumps({
|
|
"tag_name": tag,
|
|
# Pinned, rather than left to the server. Without it Gitea tags the default-branch HEAD as it is
|
|
# when the release is created, which is a different commit whenever the push has not happened yet.
|
|
"target_commitish": head,
|
|
"name": f"simadmin-relay agent {version}",
|
|
"body": (
|
|
"Outbound-only node agent for SimAdmin nodes, cross-compiled for the ARM Debian boxes SimAdmin "
|
|
"runs on. Static, no runtime dependencies, no third-party modules.\n\n"
|
|
"Install on a node:\n\n"
|
|
"```sh\n"
|
|
f"curl -fsSL https://HOST/SLUG/releases/download/{tag}/msa-agent-linux-arm64 \\\n"
|
|
" -o /opt/msa-agent/msa-agent && chmod 755 /opt/msa-agent/msa-agent\n"
|
|
"/opt/msa-agent/msa-agent enroll --relay https://your-relay --code <code> \\\n"
|
|
" --write /etc/msa-agent/config.json\n"
|
|
"/opt/msa-agent/msa-agent install\n"
|
|
"```\n\n"
|
|
"`armv7` is the same build for 32-bit ARM. `SHA256SUMS` covers both.\n\n"
|
|
"Removing it again is `msa-agent uninstall` on the node; `--dry-run` shows the plan first.\n"
|
|
),
|
|
"draft": False,
|
|
"prerelease": False,
|
|
}, ensure_ascii=False))
|
|
PY
|
|
)"
|
|
BODY="${BODY//HOST/$HOST}"
|
|
BODY="${BODY//SLUG/$SLUG}"
|
|
RELEASE_ID="$(curl -sf -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
|
"${API}/releases" -d "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
|
echo "created release ${TAG} (id ${RELEASE_ID})"
|
|
fi
|
|
|
|
step "uploading binaries"
|
|
for asset in msa-agent-linux-arm64 msa-agent-linux-armv7 SHA256SUMS; do
|
|
# An asset with the same name cannot be replaced, so an existing one is deleted first: otherwise a
|
|
# re-run after a rebuild would silently leave the previous binary in place.
|
|
EXISTING_ASSET="$(curl -sf "${AUTH[@]}" "${API}/releases/${RELEASE_ID}/assets" \
|
|
| python3 -c "
|
|
import json, sys
|
|
name = sys.argv[1]
|
|
for asset in json.load(sys.stdin):
|
|
if asset['name'] == name:
|
|
print(asset['id'])
|
|
break
|
|
" "$asset" || true)"
|
|
if [[ -n "$EXISTING_ASSET" ]]; then
|
|
# Gitea's delete path carries the release id; GitHub's does not. Using GitHub's shape here returns
|
|
# 404 and, under `set -e`, killed the script silently after the first asset — which is how a
|
|
# re-publish left the old binary in place while looking like it had replaced it.
|
|
curl -sf -X DELETE "${AUTH[@]}" "${API}/releases/${RELEASE_ID}/assets/${EXISTING_ASSET}" >/dev/null
|
|
echo " replaced ${asset}"
|
|
fi
|
|
curl -sf -X POST "${AUTH[@]}" \
|
|
-F "attachment=@dist/${asset}" \
|
|
"${API}/releases/${RELEASE_ID}/assets?name=${asset}" >/dev/null
|
|
echo " uploaded ${asset}"
|
|
done
|
|
|
|
step "published"
|
|
printf 'release: https://%s/%s/releases/tag/%s\n' "$HOST" "$SLUG" "$TAG"
|
|
printf 'download: https://%s/%s/releases/download/%s/msa-agent-linux-arm64\n' "$HOST" "$SLUG" "$TAG"
|
|
printf '\n'
|
|
# The app renders the install command, so the URL it carries has to be this one. Printing it in the
|
|
# exact form DefaultContainer holds is what makes the two comparable by eye.
|
|
printf 'DefaultContainer.agentDownload must be:\n'
|
|
printf ' https://%s/%s/releases/download/%s/msa-agent-linux-arm64\n' "$HOST" "$SLUG" "$TAG"
|
|
printf '\nVerify it is fetchable with no credentials:\n'
|
|
printf ' curl -fsSL -o /dev/null -w "%%{http_code}\\n" %s\n' \
|
|
"https://${HOST}/${SLUG}/releases/download/${TAG}/msa-agent-linux-arm64"
|