Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .github/publish-git-tag.sh

This file was deleted.

37 changes: 37 additions & 0 deletions .github/scripts/publish_git_tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Tag the release commit with the version in pyproject.toml and push that tag.
# A tag that already names the release commit is left as it is, so a rerun of
# the release passes; a tag that names another commit fails the release.

set -euo pipefail

version="$(
python3 -c 'import sys, tomllib; print(tomllib.load(sys.stdin.buffer).get("project", {}).get("version", ""))' \
< pyproject.toml
)"
if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: pyproject.toml's [project] version '${version}' is not a release version" >&2
exit 1
fi

release_commit="$(git rev-parse HEAD)"
tag_ref="refs/tags/${version}"
# An annotated tag is listed twice; its peeled line (^{}) names the commit.
tagged_commit="$(
git ls-remote origin "${tag_ref}" "${tag_ref}^{}" \
| awk -v ref="${tag_ref}" '$2 == ref || $2 == ref "^{}" { commit = $1 } END { print commit }'
)"

if [ -n "${tagged_commit}" ]; then
if [ "${tagged_commit}" = "${release_commit}" ]; then
echo "Tag ${version} already names the release commit ${release_commit}"
exit 0
fi
echo "ERROR: tag ${version} names ${tagged_commit}, not the release commit ${release_commit}" >&2
exit 1
fi

git tag "${version}" "${release_commit}"
git push origin "${tag_ref}"
echo "Pushed tag ${version} for ${release_commit}"
2 changes: 1 addition & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ jobs:
env:
JOB_RESULTS: ${{ toJSON(needs) }}
- name: Publish Git Tag
run: ".github/publish-git-tag.sh"
run: bash .github/scripts/publish_git_tag.sh

migrate-v1-staging-cloud-sql:
name: Upgrade staging v1 Cloud SQL schema
Expand Down
1 change: 1 addition & 0 deletions changelog.d/4167.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Release runs tag each release commit with its version again. The tag step read the version from the removed `setup.py` and ignored the failure, so no tag was created after 3.34.3; it now reads `pyproject.toml`, pushes only the release tag, and fails if the version is missing or the tag already names another commit.
5 changes: 4 additions & 1 deletion docs/engineering/skills/ci-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ each job it needs, and each job of a reusable workflow a release job calls has

Only `publish-git-tag` needs the checks that may be skipped; its
`release_commit.py require-checks` step decides whether their results let the
release continue. `tests/unit/test_release_run_checks.py` checks all of this.
release continue. Its last step, `.github/scripts/publish_git_tag.sh`, tags the
release commit with the `[project]` version in `pyproject.toml` and pushes that
tag. It passes when the tag already names the release commit, so a rerun works,
and fails when the version is missing or the tag names another commit. `tests/unit/test_release_run_checks.py` checks all of this.
A release job that left the status check to GitHub would be skipped whenever
the checks were, and the run would still finish green.

Expand Down
182 changes: 182 additions & 0 deletions tests/unit/test_publish_git_tag_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""The release run's tag script tags the release commit with its version."""

from __future__ import annotations

import os
import subprocess
from dataclasses import dataclass
from typing import TYPE_CHECKING

import pytest

from tests.unit.cloud_run_deploy_support import REPO

if TYPE_CHECKING:
from pathlib import Path

SCRIPT = REPO / ".github/scripts/publish_git_tag.sh"
RELEASE_VERSION = "1.2.3"
PYPROJECT = f'[project]\nname = "policyengine-api"\nversion = "{RELEASE_VERSION}"\n'
GIT_IDENTITY = (
"-c",
"user.name=Release test",
"-c",
"user.email=release-test@example.test",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgsign=false",
)


@dataclass(frozen=True)
class ReleaseCheckout:
"""A clone at a release commit, with a bare repository as its origin."""

checkout: Path
origin: Path
env: dict[str, str]

def git(self, *arguments: str) -> str:
"""Run git in the clone and return its output."""
return _git(self.checkout, self.env, *arguments)

def commit(self, pyproject: str, message: str) -> str:
"""Commit ``pyproject`` as pyproject.toml and return the new commit."""
(self.checkout / "pyproject.toml").write_text(pyproject, encoding="utf-8")
self.git("add", "pyproject.toml")
self.git(*GIT_IDENTITY, "commit", "--quiet", "--message", message)
return self.git("rev-parse", "HEAD")

def origin_tags(self) -> dict[str, str]:
"""Return each tag on the origin with the commit it names."""
listing = self.git("ls-remote", "--tags", "origin")
tags: dict[str, str] = {}
for listed in listing.splitlines():
commit, reference = listed.split("\t")
tag_name = reference.removeprefix("refs/tags/").removesuffix("^{}")
tags[tag_name] = commit
return tags

def publish(self) -> subprocess.CompletedProcess[str]:
"""Run the tag script in the clone."""
return subprocess.run(
["bash", str(SCRIPT)],
cwd=self.checkout,
env=self.env,
text=True,
capture_output=True,
check=False,
)


def _git(directory: Path, env: dict[str, str], *arguments: str) -> str:
completed = subprocess.run(
["git", *arguments],
cwd=directory,
env=env,
text=True,
capture_output=True,
check=True,
)
return completed.stdout.strip()


def _release_checkout(tmp_path: Path, pyproject: str = PYPROJECT) -> ReleaseCheckout:
env = {
"HOME": str(tmp_path),
"PATH": os.environ["PATH"],
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
}
origin = tmp_path / "origin.git"
checkout = tmp_path / "checkout"
_git(tmp_path, env, "init", "--quiet", "--bare", str(origin))
_git(tmp_path, env, "init", "--quiet", str(checkout))
release = ReleaseCheckout(checkout=checkout, origin=origin, env=env)
release.commit(pyproject, "Update PolicyEngine API")
release.git("remote", "add", "origin", str(origin))
release.git("push", "--quiet", "origin", "HEAD:refs/heads/master")
return release


def test_tags_the_release_commit_with_its_pyproject_version(tmp_path):
release = _release_checkout(tmp_path)

result = release.publish()

assert result.returncode == 0, result.stderr
assert release.origin_tags() == {RELEASE_VERSION: release.git("rev-parse", "HEAD")}


def test_pushes_only_the_release_tag(tmp_path):
release = _release_checkout(tmp_path)
release.git("tag", "local-only")

result = release.publish()

assert result.returncode == 0, result.stderr
assert set(release.origin_tags()) == {RELEASE_VERSION}


@pytest.mark.parametrize(
"pyproject",
[
'[project]\nname = "policyengine-api"\n',
'[project]\nname = "policyengine-api"\nversion = "latest"\n',
],
ids=["no-version", "not-a-release-version"],
)
def test_fails_without_a_release_version(tmp_path, pyproject):
release = _release_checkout(tmp_path, pyproject)

result = release.publish()

assert result.returncode != 0
assert "version" in result.stderr
assert release.origin_tags() == {}


def test_fails_when_the_tag_names_another_commit(tmp_path):
release = _release_checkout(tmp_path)
earlier_commit = release.git("rev-parse", "HEAD")
release.git(
"push", "--quiet", "origin", f"{earlier_commit}:refs/tags/{RELEASE_VERSION}"
)
release_commit = release.commit(
PYPROJECT + "# a later release commit\n", "Update PolicyEngine API"
)

result = release.publish()

assert result.returncode != 0
assert earlier_commit in result.stderr
assert release_commit in result.stderr
assert release.origin_tags() == {RELEASE_VERSION: earlier_commit}


@pytest.mark.parametrize("annotated", [False, True], ids=["lightweight", "annotated"])
def test_accepts_a_tag_that_already_names_the_release_commit(tmp_path, annotated):
release = _release_checkout(tmp_path)
release_commit = release.git("rev-parse", "HEAD")
if annotated:
release.git(
*GIT_IDENTITY, "tag", "--annotate", "--message", "Release", "existing"
)
release.git(
"push",
"--quiet",
"origin",
f"refs/tags/existing:refs/tags/{RELEASE_VERSION}",
)
release.git("tag", "--delete", "existing")
else:
release.git("push", "--quiet", "origin", f"HEAD:refs/tags/{RELEASE_VERSION}")
tags_before = release.git("ls-remote", "--tags", "origin")

result = release.publish()

assert result.returncode == 0, result.stderr
assert release.git("ls-remote", "--tags", "origin") == tags_before
assert release.origin_tags()[RELEASE_VERSION] == release_commit
Loading