From f296046788fd3351c220fcb0d33fe018cfe03168 Mon Sep 17 00:00:00 2001 From: rcbjBlueMars <9321971+rcbjBlueMars@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:29:02 -0800 Subject: [PATCH] Release automatically when the library changes, with a GitHub Release (#32) Publish workflows now decide for themselves. When the library's files changed since the last release's tag, a pull request merged into `main` takes the next patch number, publishes, and creates the tag and a GitHub Release. A change to tests, the demo, or the docs releases nothing. Raising `VERSION` still chooses a version. --- .github/scripts/release_decision.py | 170 ++++++++++++++++++++++++++++ .github/workflows/publish.yml | 73 ++++++------ CLAUDE.md | 2 +- README.md | 2 +- 4 files changed, 209 insertions(+), 38 deletions(-) create mode 100644 .github/scripts/release_decision.py diff --git a/.github/scripts/release_decision.py b/.github/scripts/release_decision.py new file mode 100644 index 0000000..95db483 --- /dev/null +++ b/.github/scripts/release_decision.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC +# SPDX-License-Identifier: MIT +"""Decide whether this commit on main is a release, and which version it is. + + release_decision.py decide ... + release_decision.py stamp + +kind is pypi, maven, nuget, crates, tag, or swift. package is the registry's +name for the package; tag and swift have no registry, and git tags are the +record. + +decide writes release, version, and tag to GITHUB_OUTPUT: + + - VERSION higher than anything published in its M.N: release VERSION. A hand + edit still sets a version, and a new M.N starts there. + - Otherwise, a pathspec differs from the last release's tag: release one patch + above the highest published. + - Otherwise nothing shipped has changed: no release. + +The highest version on the registry or in tags is the counter. A version that is +on the registry with no tag means an earlier run stopped between upload and +release, so decide fails rather than choose a baseline. + +stamp writes the version into the manifests in this workspace only. Nothing is +committed, so main is never rewritten and develop and main stay one commit. +""" +import json +import os +import pathlib +import re +import subprocess +import sys +import urllib.error +import urllib.request + +AGENT = {"User-Agent": "release-decision (github.com/rcbj)"} +VER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def fetch(url): + req = urllib.request.Request(url, headers=AGENT) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.read().decode() + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise SystemExit("%s returned %d" % (url, e.code)) + + +def published(kind, package): + if kind == "pypi": + body = fetch("https://pypi.org/pypi/%s/json" % package) + return list(json.loads(body)["releases"]) if body else [] + if kind == "maven": + group, artifact = package.split(":") + body = fetch("https://repo1.maven.org/maven2/%s/%s/maven-metadata.xml" % (group.replace(".", "/"), artifact)) + return re.findall(r"([^<]+)", body) if body else [] + if kind == "nuget": + body = fetch("https://api.nuget.org/v3-flatcontainer/%s/index.json" % package.lower()) + return json.loads(body)["versions"] if body else [] + if kind == "crates": + body = fetch("https://crates.io/api/v1/crates/%s" % package) + return [v["num"] for v in json.loads(body)["versions"]] if body else [] + if kind in ("tag", "swift"): + return [] + raise SystemExit("unknown kind %s" % kind) + + +def parse(version): + m = VER.match(version) + return tuple(int(x) for x in m.groups()) if m else None + + +def text(version): + return ".".join(str(x) for x in version) + + +def git(*args): + return subprocess.run(["git", *args], capture_output=True, text=True) + + +def tag_name(kind, version): + return text(version) if kind == "swift" else "v" + text(version) + + +def committed(): + raw = pathlib.Path("VERSION").read_text().strip() + parts = raw.split(".") + if len(parts) == 2: + parts.append("0") + version = parse(".".join(parts)) + if version is None: + raise SystemExit("VERSION must be M.N or M.N.P; it is %r" % raw) + return version + + +def choose(kind, floor, registry, tags, changed): + """Returns (version or None, why). changed takes a tag name and says whether shipped files differ from it.""" + tagged = {parse(t.lstrip("v")) for t in tags} - {None} + known = ({parse(v) for v in registry} - {None}) | tagged + same_line = [v for v in known if v[:2] == floor[:2]] + if not same_line or floor > max(same_line): + return floor, "VERSION is ahead of what is published" + last = max(same_line) + if last not in tagged: + raise SystemExit("%s is published with no tag. An earlier run stopped before its release. " + "Create it: gh release create %s --target --generate-notes" + % (text(last), tag_name(kind, last))) + if not changed(tag_name(kind, last)): + return None, "nothing shipped has changed since %s" % tag_name(kind, last) + return (last[0], last[1], last[2] + 1), "shipped files changed since %s" % tag_name(kind, last) + + +def decide(kind, package, sha, paths): + tags = git("tag", "--list").stdout.split() + + def changed(tag): + r = git("diff", "--quiet", tag, sha, "--", *paths) + if r.returncode not in (0, 1): + raise SystemExit("git diff against %s failed: %s" % (tag, r.stderr)) + return r.returncode == 1 + + version, why = choose(kind, committed(), published(kind, package), tags, changed) + print(why) + lines = ["release=%s" % ("true" if version else "false")] + if version: + lines += ["version=%s" % text(version), "tag=%s" % tag_name(kind, version)] + print("Releasing %s." % text(version)) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + +def rewrite(path, pattern, repl, count=0): + # newline="" keeps a file's own line endings. + with open(path, encoding="utf-8", newline="") as fh: + body = fh.read() + out, n = re.subn(pattern, repl, body, count=count, flags=re.M) + if n == 0: + raise SystemExit("no version found in %s" % path) + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(out) + + +def stamp(kind, new): + if kind == "pypi": + rewrite("pyproject.toml", r'^version = "[^"]+"', 'version = "%s"' % new, 1) + elif kind == "maven": + # The first in this pom is the project's own. It inherits nothing. + rewrite("library/pom.xml", r"[^<]+", "%s" % new, 1) + elif kind == "crates": + name = re.search(r'^name = "([^"]+)"', pathlib.Path("Cargo.toml").read_text(), re.M).group(1) + rewrite("Cargo.toml", r'^version = "[^"]+"', 'version = "%s"' % new, 1) + rewrite("Cargo.lock", r'^(name = "%s"\r?\nversion = ")[^"]+"' % re.escape(name), r'\g<1>%s"' % new) + else: + raise SystemExit("nothing to stamp for %s" % kind) + + +def main(argv): + if len(argv) >= 5 and argv[1] == "decide": + decide(argv[2], argv[3], argv[4], argv[5:]) + elif len(argv) == 4 and argv[1] == "stamp": + stamp(argv[2], argv[3]) + else: + raise SystemExit(__doc__) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6893265..d020733 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,25 +11,31 @@ on: permissions: contents: read +# One release at a time: each reads the version the one before it took. +concurrency: + group: release + cancel-in-progress: false + jobs: publish: - # The test workflow has already succeeded for this commit on main. - # Publish only when that commit is a pull request merged into main. - # A direct push stays unpublished. The checkout is the commit the tests - # ran. A version already on Maven Central is left there: a release there - # is permanent, and the next one is a new version. + # The test workflow has already succeeded for this commit on main. Release + # only when that commit is a pull request merged into main; a direct push + # stays unreleased. .github/scripts/release_decision.py picks the version: + # VERSION when it is ahead of what is published, one patch higher when the + # shipped files changed since the last release's tag, and otherwise no + # release at all, so a change to tests, the demo, or the docs ships nothing. + # The version is written into this workspace only and is never committed. # - # Only library/ is deployed (com.iyasec:saml2-sp); the demo never is. - # Maven Central has no trusted publishing, so this job uses four - # repository secrets: the Central Portal user token and the release - # signing key. + # Only library/ is deployed (com.iyasec:saml2-sp); the demo never is. Maven + # Central has no trusted publishing, so this job uses four repository + # secrets: the Central Portal user token and the release signing key. if: > github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' runs-on: ubuntu-latest permissions: - contents: read + contents: write pull-requests: read steps: - name: Require a pull request merged into main @@ -50,49 +56,33 @@ jobs: echo "publish=true" >> "$GITHUB_OUTPUT" fi + # Every tag: the last release's tag is what the decision compares with. - uses: actions/checkout@v4 if: steps.gate.outputs.publish == 'true' with: ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 persist-credentials: false - - name: Skip a version already on Maven Central - id: version + - name: Decide whether this commit is a release + id: decide if: steps.gate.outputs.publish == 'true' + env: + SHA: ${{ github.event.workflow_run.head_sha }} run: | - set -eu - python3 - <<'PY' - import os, urllib.error, urllib.request - import xml.etree.ElementTree as ET - ns = {"m": "http://maven.apache.org/POM/4.0.0"} - root = ET.parse("library/pom.xml").getroot() - group = root.find("m:groupId", ns).text - artifact = root.find("m:artifactId", ns).text - version = root.find("m:version", ns).text - url = "https://repo1.maven.org/maven2/%s/%s/%s/%s-%s.pom" % ( - group.replace(".", "/"), artifact, version, artifact, version) - try: - urllib.request.urlopen(url) - exists = True - except urllib.error.HTTPError as e: - if e.code != 404: - raise SystemExit("Maven Central returned %d for %s" % (e.code, url)) - exists = False - if exists: - print("%s:%s:%s is already on Maven Central." % (group, artifact, version)) - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: - fh.write("deploy=%s\n" % ("false" if exists else "true")) - PY + python3 .github/scripts/release_decision.py decide maven com.iyasec:saml2-sp "$SHA" library ':(exclude)library/src/test' - name: Deploy the library - if: steps.version.outputs.deploy == 'true' + if: steps.decide.outputs.release == 'true' env: + VERSION: ${{ steps.decide.outputs.version }} CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} MAVEN_GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }} MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | set -eu + python3 .github/scripts/release_decision.py stamp maven "$VERSION" mkdir -p "$RUNNER_TEMP/m2" # The token stays in the environment; settings.xml only names it. cat > "$RUNNER_TEMP/m2/settings.xml" <<'XML' @@ -116,3 +106,14 @@ jobs: -e MAVEN_GPG_PASSPHRASE \ maven:3.9.9-eclipse-temurin-17 \ mvn -B -ntp -Prelease -f library/pom.xml deploy + + # Publishing and this step are one release: the tag it creates is the + # baseline the next decision compares with. + - name: Create the tag and the GitHub Release + if: steps.decide.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event.workflow_run.head_sha }} + TAG: ${{ steps.decide.outputs.tag }} + run: | + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --target "$SHA" --title "$TAG" --generate-notes diff --git a/CLAUDE.md b/CLAUDE.md index 7689e3e..6d64c1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # saml2-sp -Java service provider for SAML 2.0 Web Browser SSO, plus a Shared Signals poll receiver. Version 0.1.1. MIT. `develop` and `main` are one commit. A pull request merged into `main` publishes the library (`library/` only; the demo never) to Maven Central after the test workflow succeeds on that commit. A direct push does not. `publish.yml` runs `mvn -Prelease -f library/pom.xml deploy` in the Maven container: the `release` profile adds the sources and javadoc jars, a GPG signature on every file, and the Central publishing plugin, which uploads, publishes and waits until the release is live. Maven Central has no trusted publishing, so the job uses four repository secrets: `CENTRAL_TOKEN_USERNAME` and `CENTRAL_TOKEN_PASSWORD` (a Central Portal user token) and `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` (the release signing key, whose public half is on a public key server). A release on Central is permanent: a version already there is skipped, and the next release is a new version in `VERSION` and the three poms. `-Dcentral.skipPublishing=true` builds and signs everything and uploads nothing. +Java service provider for SAML 2.0 Web Browser SSO, plus a Shared Signals poll receiver. MIT. `develop` and `main` are one commit. A pull request merged into `main` publishes the library (`library/` only; the demo never) to Maven Central after the test workflow succeeds on that commit. A direct push does not. `publish.yml` runs `mvn -Prelease -f library/pom.xml deploy` in the Maven container: the `release` profile adds the sources and javadoc jars, a GPG signature on every file, and the Central publishing plugin, which uploads, publishes and waits until the release is live. Maven Central has no trusted publishing, so the job uses four repository secrets: `CENTRAL_TOKEN_USERNAME` and `CENTRAL_TOKEN_PASSWORD` (a Central Portal user token) and `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` (the release signing key, whose public half is on a public key server). A release happens when the library's files changed since the last release's tag: `publish.yml` takes one patch above the highest published version, publishes it, and creates the matching tag and GitHub Release. A change to tests, the demo, or the docs releases nothing. To choose a version or start a new minor, raise `VERSION`; the version is stamped in CI and is not committed, so the manifests can trail what is published. `-Dcentral.skipPublishing=true` builds and signs everything and uploads nothing. ## Usage diff --git a/README.md b/README.md index bda415c..4e47d18 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Server-side SAML 2.0 service provider for Java. It sends a signed `AuthnRequest` on HTTP-Redirect or HTTP-POST, accepts a `Response` on HTTP-POST, HTTP-Redirect, or HTTP-Artifact, verifies XML signatures, decrypts encrypted assertions, and can poll a Shared Signals transmitter for CAEP and RISC events. -Version 0.1.1. License: MIT. `develop` and `main` are one commit. A pull request merged into `main` publishes the library (`library/` only; the demo never) to Maven Central after the test workflow succeeds on that commit. A direct push does not. `publish.yml` runs `mvn -Prelease -f library/pom.xml deploy` in the Maven container: the `release` profile adds the sources and javadoc jars, a GPG signature on every file, and the Central publishing plugin, which uploads, publishes and waits until the release is live. Maven Central has no trusted publishing, so the job uses four repository secrets: `CENTRAL_TOKEN_USERNAME` and `CENTRAL_TOKEN_PASSWORD` (a Central Portal user token) and `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` (the release signing key, whose public half is on a public key server). A release on Central is permanent: a version already there is skipped, and the next release is a new version in `VERSION` and the three poms. `-Dcentral.skipPublishing=true` builds and signs everything and uploads nothing. +License: MIT. `develop` and `main` are one commit. A pull request merged into `main` publishes the library (`library/` only; the demo never) to Maven Central after the test workflow succeeds on that commit. A direct push does not. `publish.yml` runs `mvn -Prelease -f library/pom.xml deploy` in the Maven container: the `release` profile adds the sources and javadoc jars, a GPG signature on every file, and the Central publishing plugin, which uploads, publishes and waits until the release is live. Maven Central has no trusted publishing, so the job uses four repository secrets: `CENTRAL_TOKEN_USERNAME` and `CENTRAL_TOKEN_PASSWORD` (a Central Portal user token) and `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` (the release signing key, whose public half is on a public key server). A release happens when the library's files changed since the last release's tag: `publish.yml` takes one patch above the highest published version, publishes it, and creates the matching tag and GitHub Release. A change to tests, the demo, or the docs releases nothing. To choose a version or start a new minor, raise `VERSION`; the version is stamped in CI and is not committed, so the manifests can trail what is published. `-Dcentral.skipPublishing=true` builds and signs everything and uploads nothing. ## Usage