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
170 changes: 170 additions & 0 deletions .github/scripts/release_decision.py
Original file line number Diff line number Diff line change
@@ -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 <kind> <package> <sha> <pathspec>...
release_decision.py stamp <kind> <version>

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"<version>([^<]+)", 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 <the commit that uploaded it> --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 <version> in this pom is the project's own. It inherits nothing.
rewrite("library/pom.xml", r"<version>[^<]+</version>", "<version>%s</version>" % 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)
73 changes: 37 additions & 36 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:wsfed-caep); 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:wsfed-caep); 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
Expand All @@ -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:wsfed-caep "$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'
Expand All @@ -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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ SPDX-License-Identifier: MIT

# wsfed-caep

Java 17 port of the server-side WS-Federation relying party. Group `com.iyasec`, artifact `wsfed-caep`, packages under `com.iyasec.wsfed`. Version 0.1.2. License MIT (`LICENSE`). 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. When this tree is published, `develop` and `main` are the same commit.
Java 17 port of the server-side WS-Federation relying party. Group `com.iyasec`, artifact `wsfed-caep`, packages under `com.iyasec.wsfed`. License MIT (`LICENSE`). 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. When this tree is published, `develop` and `main` are the same commit.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT

Server-side WS-Federation relying party for Java 17. It accepts SAML 2.0 bearer assertions, reads identity-provider federation metadata, writes relying-party metadata, verifies XML signatures, decrypts encrypted assertions, and remembers assertion ids so a bearer token cannot be replayed. A Shared Signals poll receiver for CAEP and RISC is included. The relying party does not start that receiver.

Group `com.iyasec`, artifact `wsfed-caep`, version 0.1.2. License: MIT. 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. `develop` and `main` are the same commit when this tree is published; nothing here creates a remote.
Group `com.iyasec`, artifact `wsfed-caep`. License: MIT. 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. `develop` and `main` are the same commit when this tree is published; nothing here creates a remote.

## Usage

Expand Down
Loading