Repository navigation
Unify release checks, fix upload credentials and add CodeRabbit #238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bernardladenthin
wants to merge
8
commits into
main
Choose a base branch
from
refactor/shared-release-checks
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
423bb8a
Share configurable release asset checks and snapshot publication
bernardladenthin 93866ef
Disable saved checkout credentials in release upload jobs
bernardladenthin 4ee9bd8
docs: add CodeRabbit badge
bernardladenthin 3633cae
docs: use repository-specific CodeRabbit reviews badge
bernardladenthin 2f915f0
Preserve runtime license notices and document scoped FOSSA review
bernardladenthin fa15108
Record SevenForums assessment as unresolved without asserting a relea…
bernardladenthin ef43f38
Preserve COPYRIGHT documents in shared runtime license collector
bernardladenthin d7326c9
Address review feedback on docs, badge links and signature subtests
bernardladenthin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com> | ||
| # SPDX-License-Identifier: MIT OR Apache-2.0 | ||
|
|
||
| version: 3 | ||
| project: | ||
| locator: git+github.com/bernardladenthin/srcmorph | ||
|
|
||
| # No fixed revision.branch: preserve the actual PR/main branch and commit. | ||
| # No license overrides or scope exclusions: test and runtime findings stay visible. | ||
| maven: | ||
| scope-exclude: [] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| # SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com> | ||
| # | ||
| # SPDX-License-Identifier: MIT OR Apache-2.0 | ||
| """Collect signed Maven release assets and explicitly configured extra files. | ||
|
|
||
| No repository names or native/Android layouts live here. See the workspace policy | ||
| policies/release-asset-scripts.md for the JSON contract and staging options. | ||
| """ | ||
|
|
||
| import csv | ||
| import json | ||
| import re | ||
| import shutil | ||
| import xml.etree.ElementTree as ET | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def _keys(value, allowed, context): | ||
| if not isinstance(value, dict) or set(value) - set(allowed): | ||
| raise ValueError(f"invalid {context}; allowed keys: {', '.join(allowed)}") | ||
|
|
||
|
|
||
| def _inside(root, relative): | ||
| if not isinstance(relative, str) or not relative or Path(relative).is_absolute(): | ||
| raise ValueError(f"expected a repository-relative path: {relative!r}") | ||
| path = (root / relative).resolve() | ||
| if not path.is_relative_to(root): | ||
| raise ValueError(f"path escapes repository: {relative}") | ||
| return path | ||
|
|
||
|
|
||
| def configuration(root, path=None): | ||
| """Read the explicit contract; unknown keys fail instead of silently dropping assets.""" | ||
| path = Path(path) if path is not None else Path(root) / ".github/release-assets.json" | ||
| config = json.loads(path.read_text()) | ||
| _keys(config, ("schema", "maven", "files"), "release asset configuration") | ||
| if type(config.get("schema")) is not int or config["schema"] != 1: | ||
| raise ValueError("release asset configuration requires schema: 1") | ||
| maven = config.get("maven", {}) | ||
| _keys(maven, ("standalone", "cyclonedx"), "maven configuration") | ||
| if not isinstance(maven.get("standalone", []), list): | ||
| raise ValueError("maven.standalone must be a list of module paths") | ||
| if type(maven.get("cyclonedx", False)) is not bool: | ||
| raise ValueError("maven.cyclonedx must be a boolean") | ||
| if not isinstance(config.get("files", []), list): | ||
| raise ValueError("files must be a list") | ||
| return config | ||
|
|
||
|
|
||
| def projects(root, standalone=()): | ||
| """Discover the reactor recursively, then optional separately published Maven projects.""" | ||
| root = Path(root).resolve() | ||
| seen, result = set(), [] | ||
|
|
||
| def visit(folder, reactor=True): | ||
| folder = folder.resolve() | ||
| if not folder.is_relative_to(root) or folder in seen: | ||
| raise ValueError(f"invalid or repeated Maven module: {folder}") | ||
| seen.add(folder) | ||
| pom = ET.parse(folder / "pom.xml").getroot() | ||
| artifact = pom.findtext("{*}artifactId") | ||
| version = pom.findtext("{*}version") or pom.findtext("{*}parent/{*}version") | ||
| packaging = pom.findtext("{*}packaging", "jar") | ||
| if (not artifact or not version or not re.fullmatch(r"[\w.-]+", artifact) | ||
| or not re.fullmatch(r"[\w.+-]+", version) | ||
| or packaging not in ("pom", "jar", "maven-plugin")): | ||
| raise ValueError(f"unsupported release coordinates in {folder / 'pom.xml'}") | ||
| if result and version != result[0][2]: | ||
| raise ValueError(f"release version mismatch in {folder / 'pom.xml'}: {version}") | ||
| result.append((folder, artifact, version, packaging, reactor)) | ||
| for module in pom.findall("{*}modules/{*}module"): | ||
| visit(_inside(root, str(folder.relative_to(root) / (module.text or "").strip())), reactor) | ||
|
|
||
| visit(root) | ||
| for module in standalone: | ||
| visit(_inside(root, module), reactor=False) | ||
| return result | ||
|
|
||
|
|
||
| def _classifiers(root, spec): | ||
| if isinstance(spec, dict): | ||
| _keys(spec, ("csv", "column"), "classifier CSV") | ||
| if not isinstance(spec.get("column"), str) or not spec["column"]: | ||
| raise ValueError("classifier CSV requires a column") | ||
| with _inside(root, spec.get("csv")).open() as stream: | ||
| rows = csv.DictReader(line for line in stream if line.strip() and not line.lstrip().startswith("#")) | ||
| if spec["column"] not in (rows.fieldnames or []): | ||
| raise ValueError(f"missing classifier CSV column: {spec['column']}") | ||
| spec = [row[spec["column"]] for row in rows] | ||
| if (not isinstance(spec, list) or not spec or any(not isinstance(c, str) | ||
| or not re.fullmatch(r"[\w.-]*", c) for c in spec) or len(set(spec)) != len(spec)): | ||
| raise ValueError("classifiers must be a nonempty list of unique classifier names") | ||
| return spec | ||
|
|
||
|
|
||
| def _format(template, version, classifier): | ||
| if not isinstance(template, str) or not template: | ||
| raise ValueError("file source/name must be a nonempty string") | ||
| try: | ||
| return template.format(version=version, classifier=classifier) | ||
| except (KeyError, ValueError, IndexError, AttributeError) as error: | ||
| raise ValueError(f"invalid release filename template: {template}") from error | ||
|
|
||
|
|
||
| def file_classifiers(root, index, config_path=None): | ||
| """Let packaging loops consume the same classifier list the collector checks.""" | ||
| if index < 0: | ||
| raise ValueError("file rule index must not be negative") | ||
| config = configuration(root, config_path) | ||
| try: | ||
| item = config.get("files", [])[index] | ||
| except IndexError as error: | ||
| raise ValueError(f"no file rule at index {index}") from error | ||
| _keys(item, ("source", "name", "classifiers"), "file rule") | ||
| return _classifiers(Path(root).resolve(), item.get("classifiers", [""])) | ||
|
|
||
|
|
||
| def collect(root, destination, config_path=None, *, maven_only=False, maven_assets=None): | ||
| """Copy available outputs, returning all missing files/signatures. Never hide partial output. | ||
|
|
||
| maven_only stages deploy-signed files before later packaging overwrites target/. | ||
| maven_assets reads those staged files by their final names during the final collection. | ||
| """ | ||
| root, destination = Path(root).resolve(), Path(destination).resolve() | ||
| config = configuration(root, config_path) | ||
| modules = projects(root, config.get("maven", {}).get("standalone", [])) | ||
| version, expected, errors = modules[0][2], [], [] | ||
| for folder, artifact, _, packaging, reactor in modules: | ||
| base = f"{artifact}-{version}" | ||
| outputs = [(folder / "target" / f"{base}.pom", f"{base}.pom")] | ||
| if packaging != "pom": | ||
| for classifier in ("", "-sources", "-javadoc"): | ||
| name = f"{base}{classifier}.jar" | ||
| outputs.append((folder / "target" / name, name)) | ||
| if reactor and config.get("maven", {}).get("cyclonedx", False): | ||
| outputs.append((folder / "target/bom.json", f"{base}-cyclonedx.json")) | ||
| for source, name in outputs: | ||
| expected.append((Path(maven_assets) / name if maven_assets is not None else source, name)) | ||
|
|
||
| for item in [] if maven_only else config.get("files", []): | ||
| _keys(item, ("source", "name", "classifiers"), "file rule") | ||
| for classifier in _classifiers(root, item.get("classifiers", [""])): | ||
| pattern = _format(item.get("source"), version, classifier) | ||
| _inside(root, pattern) | ||
| matches = sorted(root.glob(pattern)) | ||
| if len(matches) != 1: | ||
| errors.append(f"expected one release output matching {pattern}, found {len(matches)}") | ||
| continue | ||
| source = matches[0] | ||
| _inside(root, str(source.relative_to(root))) | ||
| name = _format(item["name"], version, classifier) if "name" in item else source.name | ||
| expected.append((source, name)) | ||
|
|
||
| names = set() | ||
| for source, name in expected: | ||
| if (Path(name).name != name or name in (".", "..") or name.endswith(".asc") | ||
| or name in names or name + ".asc" in names): | ||
| raise ValueError(f"invalid or duplicate release asset filename: {name}") | ||
| names.update((name, name + ".asc")) | ||
| if destination == source.parent.resolve(): | ||
| raise ValueError("asset destination overlaps release inputs") | ||
| destination.mkdir(parents=True, exist_ok=True) | ||
| if any(destination.iterdir()): | ||
| raise ValueError(f"asset destination must be empty: {destination}") | ||
| for source, name in expected: | ||
| for suffix in ("", ".asc"): | ||
| path = Path(str(source) + suffix) | ||
| if not path.is_file() or path.stat().st_size == 0: | ||
| errors.append(f"missing or empty release output: {path}") | ||
| continue | ||
| shutil.copyfile(path, destination / (name + suffix)) | ||
| return errors | ||
|
|
||
|
|
||
| def signature_gaps(directory): | ||
| """Presence/size check for every file type; signing itself verifies cryptographic validity.""" | ||
| directory = Path(directory) | ||
| files = sorted(directory.iterdir()) if directory.is_dir() else [] | ||
| artifacts = [path for path in files if not path.name.endswith(".asc")] | ||
| if not artifacts: | ||
| return -1, [f"no artifacts in {directory} -- the collection step produced nothing"] | ||
| errors = [] | ||
| for path in artifacts: | ||
| signature = Path(str(path) + ".asc") | ||
| if not path.is_file() or path.stat().st_size == 0: | ||
| errors.append(f"missing or empty artifact: {path.name}") | ||
| elif not signature.is_file() or signature.stat().st_size == 0: | ||
| errors.append(f"unsigned: {path.name} has no nonempty detached .asc") | ||
| for path in files: | ||
| if path.name.endswith(".asc") and not path.with_suffix("").is_file(): | ||
| errors.append(f"orphan signature: {path.name}") | ||
| return len(errors), errors |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com> | ||
| # | ||
| # SPDX-License-Identifier: MIT OR Apache-2.0 | ||
| """Update the rolling snapshot without replacing a newer publish or deleting the release. | ||
|
|
||
| The workflow serializes this entire operation with a job concurrency group. The ancestry/run | ||
| checks also reject an older build which finishes after a newer build has already published. | ||
| Available artifacts are uploaded even after an incomplete collection; only complete uploads | ||
| advance the source tag/notes and prune obsolete assets. | ||
| """ | ||
|
|
||
| import json | ||
| import re | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| from .releaseassets import signature_gaps | ||
|
|
||
|
|
||
| class GitHub: | ||
| def command(self, *args): | ||
| subprocess.run(["gh", *map(str, args)], check=True) | ||
|
|
||
| def api(self, method, endpoint, payload=None, optional=False): | ||
| args = ["gh", "api", "--method", method, endpoint] | ||
| if payload is not None: | ||
| args += ["--input", "-"] | ||
| result = subprocess.run(args, input=json.dumps(payload) if payload is not None else None, | ||
| text=True, capture_output=True) | ||
| if result.returncode: | ||
| if optional and "HTTP 404" in result.stderr: | ||
| return None | ||
| raise RuntimeError(f"GitHub {method} {endpoint}: {result.stderr.strip()}") | ||
| return json.loads(result.stdout) if result.stdout.strip() else None | ||
|
|
||
|
|
||
| def publish(repository, sha, run_id, attempt, assets_dir, complete, github=None, | ||
| *, tag="snapshot", title="Snapshot (latest)"): | ||
| """Return False for a superseded run; otherwise upload and update the rolling release.""" | ||
| if not re.fullmatch(r"[\w.-]+/[\w.-]+", repository) or not re.fullmatch(r"[0-9a-f]{40}", sha): | ||
| raise ValueError("invalid repository or published commit") | ||
| if not re.fullmatch(r"[\w][\w.-]*", tag) or ".." in tag or tag.endswith((".", ".lock")): | ||
| raise ValueError("invalid rolling release tag") | ||
| run_id, attempt = int(run_id), int(attempt) | ||
| if run_id < 1 or attempt < 1: | ||
| raise ValueError("run ID and attempt must be positive") | ||
| files = sorted(Path(assets_dir).iterdir()) | ||
| if not files or any(not path.is_file() for path in files): | ||
| raise ValueError("refusing to update snapshot without a nonempty, flat asset directory") | ||
| artifacts = [path for path in files if not path.name.endswith(".asc")] | ||
| if not artifacts: | ||
| raise ValueError("refusing to update snapshot with signatures but no artifacts") | ||
| names = {path.name for path in files} | ||
| complete = complete and signature_gaps(assets_dir)[0] == 0 | ||
| gh = github or GitHub() | ||
| repo = f"repos/{repository}" | ||
| ref = gh.api("GET", f"{repo}/git/ref/tags/{tag}", optional=True) | ||
| release = gh.api("GET", f"{repo}/releases/tags/{tag}", optional=True) | ||
| published = ref["object"] if ref else None | ||
| # Annotated tags point to tag objects, not directly to commits. | ||
| for _ in range(8): | ||
| if not published or published["type"] != "tag": | ||
| break | ||
| published = gh.api("GET", f"{repo}/git/tags/{published['sha']}")["object"] | ||
| if published and published["type"] != "commit": | ||
| raise ValueError("snapshot tag does not resolve to a commit") | ||
| if published and published["sha"] != sha: | ||
| comparison = gh.api("GET", f"{repo}/compare/{published['sha']}...{sha}") | ||
| if comparison["status"] == "behind": | ||
| print("Snapshot already names a newer commit; skipping this older build.") | ||
| return False | ||
| if comparison["status"] not in ("ahead", "identical"): | ||
| raise ValueError("refusing to replace snapshot with a divergent commit") | ||
| if release: | ||
| marker = re.search(r"<!-- snapshot-publish (\d+) (\d+) ([0-9a-f]{40}) -->", | ||
| release.get("body") or "") | ||
| if marker and marker[3] == sha and (int(marker[1]), int(marker[2])) > (run_id, attempt): | ||
| print("Snapshot already names a newer run/attempt of this commit; skipping.") | ||
| return False | ||
|
|
||
| body = (f"Snapshot from [{sha[:8]}](https://github.com/{repository}/commit/{sha}).\n\n" | ||
| f"[Publish run {run_id}, attempt {attempt}]" | ||
| f"(https://github.com/{repository}/actions/runs/{run_id}/attempts/{attempt}).\n\n" | ||
| f"<!-- snapshot-publish {run_id} {attempt} {sha} -->") | ||
| if not release: | ||
| if not ref: | ||
| ref = gh.api("POST", f"{repo}/git/refs", {"ref": f"refs/tags/{tag}", "sha": sha}) | ||
| release = gh.api("POST", f"{repo}/releases", { | ||
| "tag_name": tag, "target_commitish": sha, "name": title, | ||
| "prerelease": True, "body": body if complete else body + "\n\nIncomplete artifact collection." | ||
| }) | ||
| gh.command("release", "upload", tag, *files, "--repo", repository, "--clobber") | ||
| if not complete: | ||
| raise ValueError("available files uploaded, but collection is incomplete; " | ||
| "existing snapshot metadata and assets were retained") | ||
|
|
||
| if ref: | ||
| gh.api("PATCH", f"{repo}/git/refs/tags/{tag}", {"sha": sha, "force": True}) | ||
| else: | ||
| gh.api("POST", f"{repo}/git/refs", {"ref": f"refs/tags/{tag}", "sha": sha}) | ||
| gh.api("PATCH", f"{repo}/releases/{release['id']}", { | ||
| "target_commitish": sha, "name": title, "prerelease": True, "body": body | ||
| }) | ||
|
|
||
| # Read every page before deleting: deleting while paginating could skip shifted entries. | ||
| assets = [] | ||
| page = 1 | ||
| while True: | ||
| batch = gh.api("GET", f"{repo}/releases/{release['id']}/assets?per_page=100&page={page}") | ||
| assets.extend(batch) | ||
| if len(batch) < 100: | ||
| break | ||
| page += 1 | ||
| failures = [] | ||
| for asset in assets: | ||
| if asset["name"] not in names: | ||
| try: | ||
| print(f"Removing obsolete snapshot asset: {asset['name']}") | ||
| gh.api("DELETE", f"{repo}/releases/assets/{asset['id']}", optional=True) | ||
| except RuntimeError as error: | ||
| failures.append(str(error)) | ||
| if failures: | ||
| raise RuntimeError("; ".join(failures)) | ||
| return True | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔎 Supported by static analysis
Script executed:
Repository: bernardladenthin/srcmorph
Length of output: 7235
Do not overwrite published assets from an incomplete collection.
gh release upload ... --clobberruns before theif not complete:gate. It sends every file inassets_dir. For each file whose name already exists on the release, the upload replaces the published asset. The gate then raises, but the replacement has already happened. The tag and release notes are not updated before the gate, so they still describe the previous snapshot. The asset set does not stay unchanged.This can pair new bytes with an old signature. If a partial run uploads
X.jarwithoutX.jar.asc, the newX.jarreplaces the old one. The oldX.jar.ascremains, because pruning runs after the gate. The error message "existing snapshot metadata and assets were retained" is also false for these names.Upload only the files whose names are not yet published when the collection is incomplete:
Suggested fix