feat(lead): stack lead release publish, served at reach /releases behind SSO
Some checks failed
CI / lint (push) Successful in 47s
CI / notebooks-smoke (push) Successful in 2m40s
CI / test (push) Has been cancelled
Deploy / changes (push) Has started running
Deploy / notebooks (push) Has been cancelled
Deploy / zotero (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Deploy / report (push) Has been cancelled
Infra CI / zotero (push) Successful in 26s
Infra CI / docs (push) Successful in 42s
Infra CI / notebooks (push) Successful in 1m59s
Infra CI / api (push) Successful in 2m19s
Infra CI / bcda (push) Successful in 3m0s
Infra CI / llm (push) Successful in 1m11s
Infra CI / mc (push) Successful in 1m12s
Package Supply Chain / pkg-supply-chain (push) Successful in 4m9s
Some checks failed
CI / lint (push) Successful in 47s
CI / notebooks-smoke (push) Successful in 2m40s
CI / test (push) Has been cancelled
Deploy / changes (push) Has started running
Deploy / notebooks (push) Has been cancelled
Deploy / zotero (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Deploy / report (push) Has been cancelled
Infra CI / zotero (push) Successful in 26s
Infra CI / docs (push) Successful in 42s
Infra CI / notebooks (push) Successful in 1m59s
Infra CI / api (push) Successful in 2m19s
Infra CI / bcda (push) Successful in 3m0s
Infra CI / llm (push) Successful in 1m11s
Infra CI / mc (push) Successful in 1m12s
Package Supply Chain / pkg-supply-chain (push) Successful in 4m9s
publish refuses a bag that does not verify or a version already out, then copies it under data/lead/releases (served read-only by the landing nginx at reach.<domain>/releases/ only, through git-sso like the rest of reach), rewrites index.json/index.html, archives a tarball to rustfs bucket lead-releases via the mc container's rc, creates the Gitea release lead-review-v<version> at the bag's commit, and adds a bib item with the RO-Crate attached. The bags hold the stack's source, so they stay behind SSO until a decision to publish them openly.
This commit is contained in:
@@ -609,6 +609,7 @@ services:
|
||||
volumes:
|
||||
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./infra/nginx/healthz-headers.conf:/etc/nginx/healthz-headers.conf:ro
|
||||
- ./data/lead/releases:/usr/share/nginx/releases:ro
|
||||
- ./infra/nginx/index.html:/usr/share/nginx/html/index.html:ro
|
||||
- ./assets/css/dashboard.css:/usr/share/nginx/html/dashboard.css:ro
|
||||
- ./assets/css/inject.css:/usr/share/nginx/html/fhirworx.css:ro
|
||||
|
||||
@@ -24,6 +24,16 @@ http {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# LEAD living-review releases (BagIt bags). Only on the reach host:
|
||||
# traefik routes reach.<domain>/releases here behind git-sso; the
|
||||
# public dashboard host never serves them.
|
||||
location /releases/ {
|
||||
if ($host !~* "^reach\.") { return 404; }
|
||||
alias /usr/share/nginx/releases/;
|
||||
autoindex on;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Service health for the landing panels. The Gitea home fetches these
|
||||
# cross-origin, so the status code has to be readable from any host.
|
||||
# Each name is one fixed upstream. Nothing here is an open proxy.
|
||||
|
||||
@@ -97,6 +97,27 @@ http:
|
||||
middlewares:
|
||||
- projects-ratelimit
|
||||
- secure-headers
|
||||
# LEAD living-review releases: reach.<domain>/releases is served by the
|
||||
# landing nginx from data/lead/releases, behind the same SSO as reach.
|
||||
reach-releases:
|
||||
rule: "Host(`reach.{{ $domain }}`) && PathPrefix(`/releases`)"
|
||||
priority: 1000
|
||||
service: dashboard
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- git-sso
|
||||
- secure-headers
|
||||
reach-releases-tls:
|
||||
rule: "Host(`reach.{{ $domain }}`) && PathPrefix(`/releases`)"
|
||||
priority: 1000
|
||||
service: dashboard
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
middlewares:
|
||||
- git-sso
|
||||
- secure-headers
|
||||
{{- range $name, $svc := $reef }}
|
||||
{{ $name }}:
|
||||
rule: "Host(`{{ get $svc "subdomain" | default $name }}.{{ $domain }}`){{ range get $svc "extra_hosts" | default list }} || Host(`{{ . }}`){{ end }}"
|
||||
|
||||
@@ -257,6 +257,55 @@ def release_build(
|
||||
typer.echo(f"commit\t{result.commit}")
|
||||
|
||||
|
||||
@release_app.command("publish")
|
||||
def release_publish(
|
||||
path: str = typer.Argument(..., help="Path to a built release bag."),
|
||||
serve_root: str = typer.Option(
|
||||
"data/lead/releases",
|
||||
"--serve-root",
|
||||
help="Directory reach serves /releases from.",
|
||||
),
|
||||
base_url: str = typer.Option(
|
||||
"https://reach.fhirworx.io/releases",
|
||||
"--base-url",
|
||||
help="Public URL of serve-root.",
|
||||
),
|
||||
archive: bool = typer.Option(
|
||||
True, "--archive/--no-archive", help="Tarball to rustfs."
|
||||
),
|
||||
gitea: bool = typer.Option(True, "--gitea/--no-gitea", help="Gitea release + tag."),
|
||||
bib: bool = typer.Option(
|
||||
True, "--bib/--no-bib", help="Bib item with the RO-Crate."
|
||||
),
|
||||
) -> None:
|
||||
"""Publish a verified bag: serve it at reach /releases (same SSO as
|
||||
reach), archive it to rustfs, create the Gitea release, add a bib item.
|
||||
Refuses a bag that does not verify or a version already published."""
|
||||
from pathlib import Path
|
||||
|
||||
from lead.release.publish import (
|
||||
bib_release_item,
|
||||
gitea_release_via_api,
|
||||
publish,
|
||||
rc_archive,
|
||||
)
|
||||
|
||||
try:
|
||||
out = publish(
|
||||
Path(path),
|
||||
serve_root=Path(serve_root),
|
||||
base_url=base_url,
|
||||
archive=rc_archive if archive else None,
|
||||
gitea_release=gitea_release_via_api if gitea else None,
|
||||
bib_item=bib_release_item if bib else None,
|
||||
)
|
||||
except (ValueError, FileExistsError) as e:
|
||||
typer.echo(f"refused: {e}", err=True)
|
||||
raise typer.Exit(1) from e
|
||||
for k, v in out.items():
|
||||
typer.echo(f"{k}\t{v}")
|
||||
|
||||
|
||||
@release_app.command("verify")
|
||||
def release_verify(
|
||||
path: str = typer.Argument(..., help="Path to a release bag directory."),
|
||||
|
||||
235
src/lead/release/publish.py
Normal file
235
src/lead/release/publish.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Publish a verified release bag: serve it, archive it, announce it.
|
||||
|
||||
``publish`` copies the bag under the serving root (``data/lead/releases``,
|
||||
served read-only at ``reach.<domain>/releases/`` behind the same SSO as
|
||||
reach), rewrites the release index, archives a tarball to rustfs, creates
|
||||
the Gitea release ``lead-review-v<version>`` at the bag's commit, and adds a
|
||||
bib item. A published version is never replaced. Every outward step is a
|
||||
callable so tests and dry runs stay hermetic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from lead.release.build import verify_release
|
||||
|
||||
Archive = Callable[[Path, str], None]
|
||||
|
||||
|
||||
def read_bag_info(bag: Path) -> dict[str, str]:
|
||||
"""``bag-info.txt`` as a dict; folded continuation lines are joined."""
|
||||
info: dict[str, str] = {}
|
||||
last = ""
|
||||
for line in (bag / "bag-info.txt").read_text(encoding="utf-8").splitlines():
|
||||
if line[:1] in (" ", "\t") and last:
|
||||
info[last] = f"{info[last]} {line.strip()}"
|
||||
elif ":" in line:
|
||||
label, value = line.split(":", 1)
|
||||
last = label.strip()
|
||||
info[last] = value.strip()
|
||||
return info
|
||||
|
||||
|
||||
def _release_rows(serve_root: Path, base_url: str) -> list[dict]:
|
||||
rows = []
|
||||
for bag in sorted(serve_root.glob("lead-review-v*")):
|
||||
if not (bag / "bag-info.txt").is_file():
|
||||
continue
|
||||
info = read_bag_info(bag)
|
||||
rows.append(
|
||||
{
|
||||
"version": info.get("Version", ""),
|
||||
"name": bag.name,
|
||||
"date": info.get("Bagging-Date", ""),
|
||||
"commit": info.get("Git-Commit", ""),
|
||||
"size": info.get("Bag-Size", ""),
|
||||
"payload_oxum": info.get("Payload-Oxum", ""),
|
||||
"note": info.get("Internal-Sender-Description", ""),
|
||||
"url": f"{base_url.rstrip('/')}/{bag.name}/",
|
||||
}
|
||||
)
|
||||
|
||||
def key(row: dict) -> tuple:
|
||||
return tuple(int(p) if p.isdigit() else 0 for p in row["version"].split("."))
|
||||
|
||||
return sorted(rows, key=key, reverse=True)
|
||||
|
||||
|
||||
def write_index(serve_root: Path, *, base_url: str) -> list[dict]:
|
||||
"""``index.json`` and a plain ``index.html`` listing every served
|
||||
release, newest first."""
|
||||
rows = _release_rows(serve_root, base_url)
|
||||
(serve_root / "index.json").write_text(
|
||||
json.dumps({"releases": rows}, indent=1) + "\n", encoding="utf-8"
|
||||
)
|
||||
items = "\n".join(
|
||||
f'<li><a href="{html.escape(r["name"])}/">{html.escape(r["name"])}</a> — '
|
||||
f"{html.escape(r['date'])}, {html.escape(r['size'])}, commit "
|
||||
f"<code>{html.escape(r['commit'][:12])}</code>. {html.escape(r['note'])}</li>"
|
||||
for r in rows
|
||||
)
|
||||
(serve_root / "index.html").write_text(
|
||||
"<!doctype html><meta charset=utf-8><title>LEAD living review releases</title>"
|
||||
"<h1>LEAD living review releases</h1>"
|
||||
"<p>Each release is a BagIt bag with RO-Crate metadata. Protocol: "
|
||||
'<a href="https://doi.org/10.17605/OSF.IO/8WQ3M">OSF 8WQ3M</a>.</p>'
|
||||
f"<ul>\n{items}\n</ul>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def publish(
|
||||
bag: Path,
|
||||
*,
|
||||
serve_root: Path,
|
||||
base_url: str,
|
||||
archive: Archive | None,
|
||||
gitea_release: Callable[..., str] | None,
|
||||
bib_item: Callable[..., str] | None,
|
||||
) -> dict[str, str]:
|
||||
"""Publish *bag*; raises before any outward step when it does not
|
||||
verify or its version is already published."""
|
||||
problems = verify_release(bag)
|
||||
if problems:
|
||||
raise ValueError(f"{bag} does not verify: " + "; ".join(problems[:5]))
|
||||
info = read_bag_info(bag)
|
||||
version = info.get("Version", "")
|
||||
if not version:
|
||||
raise ValueError(f"{bag}: bag-info.txt has no Version")
|
||||
serve_root.mkdir(parents=True, exist_ok=True)
|
||||
served = serve_root / bag.name
|
||||
if served.resolve() != bag.resolve():
|
||||
if served.exists():
|
||||
raise FileExistsError(f"{served} is already published")
|
||||
shutil.copytree(bag, served)
|
||||
url = f"{base_url.rstrip('/')}/{bag.name}/"
|
||||
write_index(serve_root, base_url=base_url)
|
||||
out = {"version": version, "url": url, "served": str(served)}
|
||||
if archive is not None:
|
||||
archive(served, bag.name)
|
||||
out["archived"] = f"lead-releases/{bag.name}.tar"
|
||||
if gitea_release is not None:
|
||||
out["gitea"] = gitea_release(
|
||||
tag=f"lead-review-v{version}",
|
||||
commit=info.get("Git-Commit", ""),
|
||||
name=f"LEAD living review {version}",
|
||||
body=(
|
||||
f"{info.get('Internal-Sender-Description', '')}\n\n"
|
||||
f"Bag: {url}\nPayload-Oxum: {info.get('Payload-Oxum', '')}\n"
|
||||
"Protocol: https://doi.org/10.17605/OSF.IO/8WQ3M"
|
||||
),
|
||||
)
|
||||
if bib_item is not None:
|
||||
out["bib"] = bib_item(
|
||||
title=f"LEAD living review release {version}",
|
||||
url=url,
|
||||
version=version,
|
||||
date=info.get("Bagging-Date", ""),
|
||||
note=info.get("Internal-Sender-Description", ""),
|
||||
crate=served / "data" / "ro-crate-metadata.json",
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── live adapters (rustfs via the mc container, Gitea API, bib) ─────────
|
||||
|
||||
|
||||
def rc_archive(served: Path, name: str) -> None: # pragma: no cover — live rustfs
|
||||
"""Archive *served* as one tarball in rustfs bucket ``lead-releases``
|
||||
through the ``mc`` container's authenticated ``rc`` client."""
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tar_path = Path(tmp) / f"{name}.tar"
|
||||
with tarfile.open(tar_path, "w") as tar:
|
||||
tar.add(served, arcname=name)
|
||||
steps = [
|
||||
["docker", "cp", str(tar_path), f"mc:/tmp/{name}.tar"],
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"mc",
|
||||
"rc",
|
||||
"bucket",
|
||||
"create",
|
||||
"--ignore-existing",
|
||||
"local/lead-releases",
|
||||
],
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"mc",
|
||||
"rc",
|
||||
"object",
|
||||
"copy",
|
||||
f"/tmp/{name}.tar",
|
||||
f"local/lead-releases/{name}.tar",
|
||||
],
|
||||
["docker", "exec", "mc", "rm", "-f", f"/tmp/{name}.tar"],
|
||||
]
|
||||
for cmd in steps:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=600)
|
||||
|
||||
|
||||
def gitea_release_via_api( # pragma: no cover — live Gitea
|
||||
*,
|
||||
tag: str,
|
||||
commit: str,
|
||||
name: str,
|
||||
body: str,
|
||||
owner: str = "homelab",
|
||||
repo: str = "stack",
|
||||
) -> str:
|
||||
"""Create the Gitea release (and its tag) at *commit*; an existing
|
||||
release for *tag* is returned as is."""
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from api.clients.gitea.client import GiteaClient
|
||||
|
||||
client = GiteaClient(os.environ["GITEA_TOKEN"])
|
||||
try:
|
||||
found = client.get(f"/repos/{owner}/{repo}/releases/tags/{tag}")
|
||||
return found.json().get("html_url", "")
|
||||
except Exception: # noqa: BLE001 — 404: not released yet
|
||||
pass
|
||||
resp = client.post(
|
||||
f"/repos/{owner}/{repo}/releases",
|
||||
json={"tag_name": tag, "target_commitish": commit, "name": name, "body": body},
|
||||
)
|
||||
if isinstance(resp, httpx.Response):
|
||||
return resp.json().get("html_url", "")
|
||||
return ""
|
||||
|
||||
|
||||
def bib_release_item( # pragma: no cover — live bib
|
||||
*, title: str, url: str, version: str, date: str, note: str, crate: Path
|
||||
) -> str:
|
||||
"""A bib ``download`` item for the release, with its RO-Crate attached."""
|
||||
from bib import connect
|
||||
from bib.item import Download
|
||||
|
||||
store = connect()
|
||||
item = Download(
|
||||
title=title,
|
||||
url=url,
|
||||
date_published=date,
|
||||
institution="LEAD living review",
|
||||
page_type="lead_release",
|
||||
file_urls=[url],
|
||||
extra=note,
|
||||
)
|
||||
for tag in ("source:lead-release", "project:lead", f"lead-release:{version}"):
|
||||
item.add_tag(tag)
|
||||
key = store.upsert(item)
|
||||
store.attach_file(key, crate, title=f"{title} — RO-Crate metadata")
|
||||
return key
|
||||
191
tests/lead/test_release_publish.py
Normal file
191
tests/lead/test_release_publish.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""stack lead release publish — serve, archive, announce a verified bag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lead.release.bagit import write_bag
|
||||
from lead.release.publish import publish, read_bag_info, write_index
|
||||
|
||||
|
||||
def _bag(root: Path, version: str = "0.1") -> Path:
|
||||
bag = root / f"lead-review-v{version}"
|
||||
(bag / "data").mkdir(parents=True)
|
||||
(bag / "data" / "protocol.md").write_text("# protocol\n")
|
||||
(bag / "data" / "ro-crate-metadata.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"@context": "https://w3id.org/ro/crate/1.2/context",
|
||||
"@graph": [
|
||||
{
|
||||
"@id": "ro-crate-metadata.json",
|
||||
"@type": "CreativeWork",
|
||||
"about": {"@id": "./"},
|
||||
},
|
||||
{
|
||||
"@id": "./",
|
||||
"@type": "Dataset",
|
||||
"name": "LEAD living review",
|
||||
"description": "test release",
|
||||
"datePublished": "2026-11-11",
|
||||
"license": {
|
||||
"@id": "https://creativecommons.org/licenses/by/4.0/"
|
||||
},
|
||||
"hasPart": [{"@id": "protocol.md"}],
|
||||
},
|
||||
{
|
||||
"@id": "protocol.md",
|
||||
"@type": "File",
|
||||
"sha256": __import__("hashlib")
|
||||
.sha256(b"# protocol\n")
|
||||
.hexdigest(),
|
||||
"contentSize": "11",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
write_bag(
|
||||
bag,
|
||||
[
|
||||
("Version", version),
|
||||
("Git-Commit", "a" * 40),
|
||||
("Bagging-Date", "2026-11-11"),
|
||||
("Internal-Sender-Description", "first release"),
|
||||
],
|
||||
)
|
||||
return bag
|
||||
|
||||
|
||||
class Calls:
|
||||
def __init__(self) -> None:
|
||||
self.archived: list[tuple[Path, str]] = []
|
||||
self.releases: list[dict] = []
|
||||
self.bib: list[dict] = []
|
||||
|
||||
def archive(self, bag: Path, name: str) -> None:
|
||||
self.archived.append((bag, name))
|
||||
|
||||
def gitea_release(self, **kw) -> str:
|
||||
self.releases.append(kw)
|
||||
return "https://git.test/rel/1"
|
||||
|
||||
def bib_item(self, **kw) -> str:
|
||||
self.bib.append(kw)
|
||||
return "KEY12345"
|
||||
|
||||
|
||||
def test_read_bag_info_joins_continuation_lines(tmp_path):
|
||||
bag = _bag(tmp_path)
|
||||
info = read_bag_info(bag)
|
||||
assert info["Version"] == "0.1" and info["Git-Commit"] == "a" * 40
|
||||
assert info["Internal-Sender-Description"] == "first release"
|
||||
|
||||
|
||||
def test_publish_serves_archives_and_announces(tmp_path):
|
||||
bag = _bag(tmp_path / "build")
|
||||
serve = tmp_path / "serve"
|
||||
calls = Calls()
|
||||
out = publish(
|
||||
bag,
|
||||
serve_root=serve,
|
||||
base_url="https://reach.test/releases",
|
||||
archive=calls.archive,
|
||||
gitea_release=calls.gitea_release,
|
||||
bib_item=calls.bib_item,
|
||||
)
|
||||
served = serve / bag.name
|
||||
assert (served / "bagit.txt").exists()
|
||||
assert calls.archived == [(served, bag.name)]
|
||||
assert calls.releases[0]["tag"] == "lead-review-v0.1"
|
||||
assert calls.releases[0]["commit"] == "a" * 40
|
||||
assert calls.bib[0]["url"] == "https://reach.test/releases/lead-review-v0.1/"
|
||||
index = json.loads((serve / "index.json").read_text())
|
||||
assert index["releases"][0]["version"] == "0.1"
|
||||
assert out["url"] == "https://reach.test/releases/lead-review-v0.1/"
|
||||
|
||||
|
||||
def test_publish_refuses_a_bag_that_does_not_verify(tmp_path):
|
||||
bag = _bag(tmp_path / "build")
|
||||
(bag / "data" / "protocol.md").write_text("tampered\n")
|
||||
calls = Calls()
|
||||
with pytest.raises(ValueError, match="does not verify"):
|
||||
publish(
|
||||
bag,
|
||||
serve_root=tmp_path / "serve",
|
||||
base_url="https://reach.test/releases",
|
||||
archive=calls.archive,
|
||||
gitea_release=calls.gitea_release,
|
||||
bib_item=calls.bib_item,
|
||||
)
|
||||
assert not calls.archived and not calls.releases
|
||||
|
||||
|
||||
def test_publish_never_overwrites_a_published_version(tmp_path):
|
||||
calls = Calls()
|
||||
kw = dict(
|
||||
serve_root=tmp_path / "serve",
|
||||
base_url="https://reach.test/releases",
|
||||
archive=calls.archive,
|
||||
gitea_release=calls.gitea_release,
|
||||
bib_item=calls.bib_item,
|
||||
)
|
||||
publish(_bag(tmp_path / "a"), **kw)
|
||||
other = _bag(tmp_path / "b")
|
||||
(other / "data" / "protocol.md").write_text("# protocol\n") # same name
|
||||
with pytest.raises(FileExistsError):
|
||||
publish(other, **kw)
|
||||
|
||||
|
||||
def test_index_lists_every_served_release_newest_first(tmp_path):
|
||||
serve = tmp_path / "serve"
|
||||
for v in ("0.1", "0.2"):
|
||||
bag = _bag(tmp_path / f"b{v}", v)
|
||||
(serve).mkdir(exist_ok=True)
|
||||
bag.rename(serve / bag.name)
|
||||
write_index(serve, base_url="https://reach.test/releases")
|
||||
index = json.loads((serve / "index.json").read_text())
|
||||
assert [r["version"] for r in index["releases"]] == ["0.2", "0.1"]
|
||||
assert (serve / "index.html").read_text().count("lead-review-v") >= 2
|
||||
|
||||
|
||||
def test_cli_publish_offline(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cli.lead import app
|
||||
|
||||
bag = _bag(tmp_path / "build")
|
||||
res = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"release",
|
||||
"publish",
|
||||
str(bag),
|
||||
"--serve-root",
|
||||
str(tmp_path / "serve"),
|
||||
"--base-url",
|
||||
"https://reach.test/releases",
|
||||
"--no-archive",
|
||||
"--no-gitea",
|
||||
"--no-bib",
|
||||
],
|
||||
)
|
||||
assert res.exit_code == 0, res.output
|
||||
assert "url\thttps://reach.test/releases/lead-review-v0.1/" in res.output
|
||||
again = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"release",
|
||||
"publish",
|
||||
str(bag),
|
||||
"--serve-root",
|
||||
str(tmp_path / "serve"),
|
||||
"--no-archive",
|
||||
"--no-gitea",
|
||||
"--no-bib",
|
||||
],
|
||||
)
|
||||
assert again.exit_code == 1 and "already published" in again.output
|
||||
Reference in New Issue
Block a user