# Attach the release files to the GitHub release of a tag v. # # 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 `, 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