Files
bastion-agent/.github/workflows/release.yml
T
Privatae e3074f50b0
ci / test (push) Successful in 1m1s
ci / personal-data (push) Successful in 3s
ci / reproducible (push) Successful in 4s
bastion agent 0.1.16
2026-10-25 23:16:50 +00:00

175 lines
8.6 KiB
YAML

# Attach the release files to the GitHub release of a tag v<version>.
#
# GitHub only: Gitea reads .gitea/workflows when it exists and then ignores
# this directory, so the forge's copy of this repository never runs this file.
#
# The files are not uploaded from anywhere else. This job rebuilds them from
# the tag and attaches them only when they are the published release:
#
# 1. bastion_agent/verify_release.py fetches the live manifest
# (privatae.ai/bastion-agent/version.json) and its signature, checks the
# signature against the release keys the bastion pins, refuses unless the
# manifest names this tag's version and the tag is annotated with the line
# `built from <built_from>`, rebuilds the .pyz from the tag with that
# tag's own build.py, and compares the sha256 with the manifest;
# 2. only then are the rebuilt .pyz, its .sha256, the manifest (its exact
# bytes) and the signature file attached to the release, which is created
# when the tag has none, with that version's CHANGELOG section.
#
# The live manifest names only the newest release, so a release can be given
# its files only while it is the published one. Older releases, whose manifest
# is no longer served, cannot be filled in afterwards. A run that failed (the
# tag pushed before the release went live, say) is run again from the Actions
# tab with the tag as input.
#
# No action from the marketplace is used, and nothing is fetched with a
# credential: the repository is read from its public URL. The job may write
# to the repository's releases; the token is handed to the last step only.
name: release
on:
push:
tags: ["v*.*.*"]
workflow_dispatch:
inputs:
tag:
description: "the release tag, vX.Y.Z"
required: true
permissions:
contents: read
env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
TAG: ${{ github.event.inputs.tag || github.ref_name }}
defaults:
run:
shell: bash -euo pipefail {0}
jobs:
release:
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
steps:
- name: the tag is a release tag
run: |
printf '%s\n' "$TAG" | grep -Exq 'v[0-9]+\.[0-9]+\.[0-9]+' \
|| { echo "not a release tag: $TAG"; exit 1; }
- name: fetch the repository and its tags
# verify_release.py is run from the commit this workflow runs from: the
# tag itself on a tag push, the default branch on a manual run. It
# builds the tag's own source in a separate worktree.
run: |
git init -q .
git remote add origin "$REPO_URL"
git fetch -q origin "+refs/tags/*:refs/tags/*" "$GITHUB_SHA"
git checkout -q --detach "$GITHUB_SHA"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
git rev-parse -q --verify "refs/tags/$TAG^{commit}" >/dev/null \
|| { echo "no tag $TAG in this repository"; exit 1; }
- name: check the signed manifest and rebuild the .pyz from the tag
run: |
# The PyYAML release bastion_agent/build.py vendors, as a wheel only
# and only with one of these sha256s (PyPI's, for Linux on x86_64 and
# arm64, Python 3.12 and 3.13): the one package this job takes from
# outside the repository, and this step runs before the upload step
# that holds the token. A different PyYAML would only make the
# rebuild differ and the step refuse.
cat > "$RUNNER_TEMP/requirements.txt" <<'REQ'
pyyaml==6.0.3 \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c
REQ
/usr/bin/python3 -m venv /tmp/venv
/tmp/venv/bin/pip install -q --disable-pip-version-check --only-binary :all: \
--require-hashes -r "$RUNNER_TEMP/requirements.txt"
/tmp/venv/bin/python -m bastion_agent.verify_release \
--expect-version "$TAG" --out "$RUNNER_TEMP/release"
ls -l "$RUNNER_TEMP/release"
- name: the release notes, from that version's CHANGELOG section
run: |
/usr/bin/git show "$TAG:CHANGELOG.md" > "$RUNNER_TEMP/CHANGELOG.md"
/usr/bin/python3 -I - "${TAG#v}" "$RUNNER_TEMP/CHANGELOG.md" > "$RUNNER_TEMP/notes.md" <<'PY'
import re, sys
ver, path = sys.argv[1], sys.argv[2]
text = open(path, encoding="utf-8").read()
m = re.search(r"^## %s[ \t]*\n(.*?)(?=^## |\Z)" % re.escape(ver), text, re.M | re.S)
if not m:
sys.exit("CHANGELOG.md at the tag has no section for %s" % ver)
print(m.group(1).strip())
print()
print("---")
print("The files below are this tag rebuilt by this repository's release workflow, "
"attached only after their sha256 matched the signed manifest "
"(`version.json` and its `.sig`). To check them yourself, run "
"`python3 -m bastion_agent.verify_release` from a clone: "
"see [Verify a release](https://github.com/%s#verify-a-release)."
% "Privatae/bastion-agent")
PY
- name: attach the files to the release
# The one step with the token. Its shell and Python are named by path,
# and Python runs isolated (-I), so nothing an earlier step put on PATH
# or in the environment chooses what runs here.
shell: /usr/bin/bash -euo pipefail {0}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
/usr/bin/python3 -I - "$RUNNER_TEMP/release" "$RUNNER_TEMP/notes.md" <<'PY'
import json, os, sys, urllib.error, urllib.parse, urllib.request
out_dir, notes = sys.argv[1], sys.argv[2]
api = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
repo, tag, token = os.environ["GH_REPO"], os.environ["TAG"], os.environ["GH_TOKEN"]
def call(method, url, body=None, ctype="application/json"):
data = body if isinstance(body, bytes) or body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": "Bearer " + token, "Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28", "Content-Type": ctype,
"User-Agent": "bastion-agent-release"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read()
return r.status, json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
return e.code, None
st, rel = call("GET", "%s/repos/%s/releases/tags/%s" % (api, repo, tag))
if st == 404:
body = open(notes, encoding="utf-8").read()
st, rel = call("POST", "%s/repos/%s/releases" % (api, repo), {
"tag_name": tag, "name": "bastion agent " + tag[1:], "body": body,
"draft": False, "prerelease": False})
if st != 201:
sys.exit("creating the release for %s failed: HTTP %d" % (tag, st))
print("created the release for %s" % tag)
elif st != 200:
sys.exit("reading the release for %s failed: HTTP %d" % (tag, st))
else:
print("the release for %s exists; its text is left as it is" % tag)
st, assets = call("GET", "%s/repos/%s/releases/%d/assets?per_page=100" % (api, repo, rel["id"]))
if st != 200:
sys.exit("listing the release's files failed: HTTP %d" % st)
have = {a["name"]: a["id"] for a in assets}
upload = rel["upload_url"].split("{", 1)[0]
for name in sorted(os.listdir(out_dir)):
if name in have:
st, _ = call("DELETE", "%s/repos/%s/releases/assets/%d" % (api, repo, have[name]))
if st != 204:
sys.exit("replacing %s failed: HTTP %d" % (name, st))
with open(os.path.join(out_dir, name), "rb") as fh:
data = fh.read()
st, a = call("POST", upload + "?" + urllib.parse.urlencode({"name": name}), data,
"application/octet-stream")
if st != 201:
sys.exit("uploading %s failed: HTTP %d" % (name, st))
print("attached %s (%d bytes)" % (a["name"], a["size"]))
PY