chore: split validation and release workflows and refactor packaging
This commit is contained in:
1 parent
5b06a795e4
commit
5f5ad058a8
9 files changed
+397
-505
No files matched your search
@@ -1,16 +0,0 @@
|
||||
# v1.27.2
|
||||
|
||||
> **Correction (2026-09-23):** The push-notification fix in this release is incomplete. Bloom, Ember and Heritage still use the old commit ID path and can fail to render push notifications on Gitea 1.27.1+. Use v1.27.3 for the complete fix; see the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix).
|
||||
|
||||
## Changes
|
||||
|
||||
- Add the `email-text` class to Aurora release paragraphs for consistent typography.
|
||||
- Update push commit paths in seven themes for Gitea 1.27.1 compatibility (`.ID` → `.UserCommit.GitCommit.ID`). The three remaining themes are covered by the correction above.
|
||||
- Replace `rgba()` colors with `#RRGGBBAA` notation across all ten themes.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Record compatibility for Gitea 1.27.0, 1.27.1 and 1.27.2.
|
||||
- Maintain English and Simplified Chinese README and contributor guides.
|
||||
|
||||
[Full changelog: v1.0.1…v1.27.2](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.0.1...v1.27.2)
|
||||
@@ -1,20 +0,0 @@
|
||||
# v1.27.3
|
||||
|
||||
This release completes the push-notification fix for Gitea 1.27.1–1.27.3. See the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix) for recommended release combinations.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fix pull request push-notification links and abbreviated hashes in Bloom, Ember and Heritage by reading `.UserCommit.GitCommit.ID`.
|
||||
- Add a push-notification regression test covering all ten themes and run Go tests before packaging.
|
||||
|
||||
## Packaging
|
||||
|
||||
- Include English and Simplified Chinese README and contributor guides in release archives.
|
||||
- Use reviewed release notes in the publishing workflow.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Record compatibility with Gitea 1.27.3, whose mail templates, mailer and locale files are unchanged from 1.27.2.
|
||||
- Distinguish the affected v1.27.2 archive from the complete fix in this release.
|
||||
|
||||
[Full changelog: v1.27.2…v1.27.3](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.27.2...v1.27.3)
|
||||
@@ -1,18 +0,0 @@
|
||||
# v28.0.0
|
||||
|
||||
This release targets Gitea 28.0.0 and includes ten themes with eleven mail types each. For earlier Gitea versions, use the packages listed in the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix).
|
||||
|
||||
## Fixes
|
||||
|
||||
- Replace the removed `FileSize` function with `FormatByteSize` in release-attachment emails across all ten themes.
|
||||
- Update the preview's file-size function for Gitea 28.0.0 and include release attachments in the example data.
|
||||
- Add an all-theme regression test for release attachments and the removed function.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update both READMEs and the compatibility matrix for Gitea 28.0.0.
|
||||
- Document the Gitea 28.0.0 requirement and direct users of earlier versions to the compatibility matrix.
|
||||
|
||||
The snapshot-based source architecture now on `main` is separate, unreleased work. It is not included in the existing v28.0.0 archives.
|
||||
|
||||
[Full changelog: v1.27.3…v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.27.3...v28.0.0)
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Gitea API operations for the release workflow (Python stdlib only)."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote, urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
|
||||
class NoRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# Never forward an instance token to a redirect destination.
|
||||
return None
|
||||
|
||||
|
||||
class GiteaAPI:
|
||||
def __init__(self, server, repository, token):
|
||||
parsed = urlsplit(server)
|
||||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("GITEA_SERVER_URL must be an HTTPS instance URL")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
|
||||
raise ValueError("GITEA_REPOSITORY must be owner/repository")
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN is required")
|
||||
self.base = server.rstrip("/") + "/api/v1/repos/" + repository
|
||||
self.token = token
|
||||
self.opener = build_opener(NoRedirects())
|
||||
|
||||
def request(self, method, path, data=None, content_type="application/json", allow_missing=False):
|
||||
if data is not None and not isinstance(data, bytes):
|
||||
data = json.dumps(data).encode("utf-8")
|
||||
req = Request(self.base + path, data=data, method=method, headers={
|
||||
"Authorization": "token " + self.token,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": content_type,
|
||||
"User-Agent": "GiteaMailTemplates-actions",
|
||||
})
|
||||
try:
|
||||
with self.opener.open(req, timeout=120) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as error:
|
||||
status = error.code
|
||||
error.close()
|
||||
if allow_missing and status == 404:
|
||||
return None
|
||||
raise RuntimeError(f"Gitea API {method} {path}: HTTP {status}") from None
|
||||
|
||||
|
||||
def create_issue(api, version):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", version):
|
||||
raise ValueError("Expected a stable vX.Y.Z release tag")
|
||||
marker = f"<!-- gitea-mail-templates:template-release:{version} -->"
|
||||
title = f"Update documentation for template release {version}"
|
||||
page = 1
|
||||
while True:
|
||||
issues = api.request("GET", f"/issues?state=all&type=issues&limit=50&page={page}")
|
||||
for issue in issues:
|
||||
if issue.get("pull_request") is None and (marker in (issue.get("body") or "") or issue.get("title") == title):
|
||||
print(f"[PASS] Reminder issue already exists: {issue['html_url']}")
|
||||
return issue
|
||||
if not issues:
|
||||
break
|
||||
page += 1
|
||||
body = f"""{marker}
|
||||
Template release **{version}** has been published. Documentation is maintained manually.
|
||||
|
||||
- [ ] Update the release version in the English and Chinese READMEs.
|
||||
- [ ] Review compatibility records and mark versions verified only according to test results.
|
||||
- [ ] Check release links, release notes and remaining version references.
|
||||
|
||||
This reminder does not change repository files, branches or existing release assets.
|
||||
"""
|
||||
issue = api.request("POST", "/issues", {"title": title, "body": body})
|
||||
print(f"[PASS] Created reminder issue: {issue['html_url']}")
|
||||
return issue
|
||||
|
||||
|
||||
def publish_release(api, version, root=Path(".")):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", version):
|
||||
raise ValueError("Expected a stable vX.Y.Z release tag")
|
||||
root = Path(root)
|
||||
lock = json.loads((root / "gitea.lock.json").read_text(encoding="utf-8"))
|
||||
if lock["tag"] != version:
|
||||
raise ValueError("Release tag must match gitea.lock.json")
|
||||
notes = (root / ".github" / "release-notes" / (version + ".md")).read_text(encoding="utf-8")
|
||||
if not notes.strip():
|
||||
raise ValueError("Release notes are empty")
|
||||
assets = [root / "dist" / ("gitea-mail-templates-" + version + ext) for ext in (".zip", ".tar.gz")]
|
||||
for asset in assets:
|
||||
if asset.is_symlink() or not asset.is_file() or not asset.stat().st_size:
|
||||
raise ValueError(f"Missing or invalid release archive: {asset}")
|
||||
existing = api.request("GET", "/releases/tags/" + quote(version, safe=""), allow_missing=True)
|
||||
if existing is not None:
|
||||
raise ValueError("Release already exists; refusing to replace its notes or assets")
|
||||
|
||||
release = api.request("POST", "/releases", {
|
||||
"tag_name": version, "name": version, "body": notes, "draft": True, "prerelease": False,
|
||||
})
|
||||
release_id = int(release["id"])
|
||||
# Gitea accepts raw attachment data with the filename in the query string.
|
||||
# Publish only after both uploads succeed; failures leave a draft for review.
|
||||
for asset in assets:
|
||||
api.request("POST", f"/releases/{release_id}/assets?" + urlencode({"name": asset.name}),
|
||||
asset.read_bytes(), content_type="application/octet-stream")
|
||||
release = api.request("PATCH", f"/releases/{release_id}", {"draft": False})
|
||||
print(f"[PASS] Published {release['html_url']}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("operation", choices=("create-issue", "publish-release"))
|
||||
parser.add_argument("--version", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
api = GiteaAPI(os.environ.get("GITEA_SERVER_URL", ""), os.environ.get("GITEA_REPOSITORY", ""), os.environ.get("GITEA_TOKEN", ""))
|
||||
if args.operation == "create-issue":
|
||||
create_issue(api, args.version)
|
||||
else:
|
||||
publish_release(api, args.version)
|
||||
except (ValueError, KeyError, OSError, RuntimeError) as error:
|
||||
parser.exit(1, f"[FAIL] {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,10 +4,28 @@ import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
VERSION_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+")
|
||||
THEME_NAME_PATTERN = re.compile(r"[a-z][a-z0-9-]*")
|
||||
BASE_TEMPLATES = {"mail/base/head.tmpl", "mail/base/footer.tmpl"}
|
||||
REQUIRED_FILES = (
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CONTRIBUTING.md",
|
||||
"COMPATIBILITY.md",
|
||||
"docs/README.zh-CN.md",
|
||||
"docs/CONTRIBUTING.zh-CN.md",
|
||||
"preview/index.html",
|
||||
)
|
||||
OPTIONAL_ROOT_DOCUMENTS = ("AGENTS.md", "THIRD_PARTY_NOTICES.md")
|
||||
DOCUMENTATION_DIRECTORIES = ("docs", ".github/release-notes")
|
||||
PREVIEW_MANIFEST_PREFIX = b"window.__MAIL_PREVIEW__ = "
|
||||
ARCHIVE_EXTENSIONS = (".zip", ".tar.gz")
|
||||
|
||||
|
||||
def read_file(path):
|
||||
@@ -31,101 +49,187 @@ def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def collect(root, version):
|
||||
root = Path(root)
|
||||
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version):
|
||||
def read_json(path):
|
||||
return json.loads(read_file(path))
|
||||
|
||||
|
||||
def load_lock(root, version):
|
||||
"""Validate the requested release identity before collecting any output."""
|
||||
if not VERSION_PATTERN.fullmatch(version):
|
||||
raise ValueError("Expected a stable vX.Y.Z package version")
|
||||
lock = json.loads(read_file(root / "gitea.lock.json"))
|
||||
lock = read_json(root / "gitea.lock.json")
|
||||
if version != lock["tag"]:
|
||||
raise ValueError("Package version must match the locked Gitea tag")
|
||||
files = {}
|
||||
names = sorted(p.name for p in (root / "themes").iterdir() if p.is_dir() and not p.is_symlink())
|
||||
return lock
|
||||
|
||||
|
||||
def discover_theme_names(root):
|
||||
names = sorted(
|
||||
path.name
|
||||
for path in (root / "themes").iterdir()
|
||||
if path.is_dir() and not path.is_symlink()
|
||||
)
|
||||
if not names:
|
||||
raise ValueError("No source theme manifests")
|
||||
for name in names:
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
|
||||
raise ValueError(f"Invalid theme name: {name}")
|
||||
source = root / "themes" / name
|
||||
theme = json.loads(read_file(source / "theme.json"))
|
||||
if theme["name"] != name or theme["mode"] not in ("shared", "framed"):
|
||||
raise ValueError(f"Invalid theme metadata: {name}")
|
||||
built = root / "build" / "themes" / name
|
||||
meta = json.loads(read_file(built / "build.json"))
|
||||
if (meta["theme"], meta["mode"], meta["gitea_tag"], meta["gitea_commit"]) != (name, theme["mode"], version, lock["commit"]):
|
||||
raise ValueError(f"Stale build identity: {name}")
|
||||
if not {"theme.json", "theme.css"}.issubset(meta.get("sources") or {}) or not meta.get("files"):
|
||||
raise ValueError(f"Missing source/file provenance: {name}")
|
||||
expected_files = {"mail/base/head.tmpl", "mail/base/footer.tmpl"}
|
||||
if theme["mode"] == "framed":
|
||||
expected_files.update(path.removeprefix("templates/") for path in lock["files"] if path.startswith("templates/mail/") and path.endswith(".tmpl"))
|
||||
expected_files.update(path.relative_to(root / "framework").as_posix() for path in (root / "framework" / "mail").rglob("*.tmpl"))
|
||||
if set(meta["files"]) != expected_files:
|
||||
raise ValueError(f"Incomplete generated install package: {name}")
|
||||
for path, expected in meta["sources"].items():
|
||||
base = root if path.startswith(("framework/", "tools/")) else source
|
||||
if digest(read_file(checked_path(base, path))) != expected:
|
||||
raise ValueError(f"Source changed since build: {name}/{path}")
|
||||
for path, expected in meta["files"].items():
|
||||
if not path.startswith("mail/") or not path.endswith(".tmpl"):
|
||||
raise ValueError(f"Unexpected generated file: {path}")
|
||||
data = read_file(checked_path(built, path))
|
||||
if digest(data) != expected:
|
||||
raise ValueError(f"Generated checksum mismatch: {name}/{path}")
|
||||
files[f"themes/{name}/{path}"] = data
|
||||
files[f"themes/{name}/build.json"] = read_file(built / "build.json")
|
||||
return names
|
||||
|
||||
|
||||
def expected_template_paths(root, lock, mode):
|
||||
"""Shared themes override base files; framed themes also supply all bodies."""
|
||||
paths = set(BASE_TEMPLATES)
|
||||
if mode == "framed":
|
||||
paths.update(
|
||||
path.removeprefix("templates/")
|
||||
for path in lock["files"]
|
||||
if path.startswith("templates/mail/") and path.endswith(".tmpl")
|
||||
)
|
||||
paths.update(
|
||||
path.relative_to(root / "framework").as_posix()
|
||||
for path in (root / "framework" / "mail").rglob("*.tmpl")
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def collect_theme(root, lock, name):
|
||||
"""Accept only a complete build whose identity and checksums still match."""
|
||||
if not THEME_NAME_PATTERN.fullmatch(name):
|
||||
raise ValueError(f"Invalid theme name: {name}")
|
||||
source = root / "themes" / name
|
||||
theme = read_json(source / "theme.json")
|
||||
if theme["name"] != name or theme["mode"] not in ("shared", "framed"):
|
||||
raise ValueError(f"Invalid theme metadata: {name}")
|
||||
built = root / "build" / "themes" / name
|
||||
metadata = read_json(built / "build.json")
|
||||
actual_identity = (
|
||||
metadata["theme"], metadata["mode"],
|
||||
metadata["gitea_tag"], metadata["gitea_commit"],
|
||||
)
|
||||
expected_identity = (name, theme["mode"], lock["tag"], lock["commit"])
|
||||
if actual_identity != expected_identity:
|
||||
raise ValueError(f"Stale build identity: {name}")
|
||||
has_sources = {"theme.json", "theme.css"}.issubset(metadata.get("sources") or {})
|
||||
if not has_sources or not metadata.get("files"):
|
||||
raise ValueError(f"Missing source/file provenance: {name}")
|
||||
if set(metadata["files"]) != expected_template_paths(root, lock, theme["mode"]):
|
||||
raise ValueError(f"Incomplete generated install package: {name}")
|
||||
for path, expected in metadata["sources"].items():
|
||||
base = root if path.startswith(("framework/", "tools/")) else source
|
||||
if digest(read_file(checked_path(base, path))) != expected:
|
||||
raise ValueError(f"Source changed since build: {name}/{path}")
|
||||
files = {}
|
||||
for path, expected in metadata["files"].items():
|
||||
if not path.startswith("mail/") or not path.endswith(".tmpl"):
|
||||
raise ValueError(f"Unexpected generated file: {path}")
|
||||
data = read_file(checked_path(built, path))
|
||||
if digest(data) != expected:
|
||||
raise ValueError(f"Generated checksum mismatch: {name}/{path}")
|
||||
files[f"themes/{name}/{path}"] = data
|
||||
files[f"themes/{name}/build.json"] = read_file(built / "build.json")
|
||||
return files
|
||||
|
||||
|
||||
def parse_script_payload(data, prefix):
|
||||
"""Read generated JSON without executing the surrounding JavaScript."""
|
||||
return json.loads(data.split(prefix, 1)[1].strip().removesuffix(b";"))
|
||||
|
||||
|
||||
def collect_preview(root, lock, theme_names):
|
||||
manifest_data = read_file(root / "preview" / "rendered.js")
|
||||
prefix = b"window.__MAIL_PREVIEW__ = "
|
||||
manifest = json.loads(manifest_data.split(prefix, 1)[1].strip().removesuffix(b";"))
|
||||
languages = sorted(path.removeprefix("options/locale/locale_").removesuffix(".json") for path in lock["files"] if path.startswith("options/locale/locale_") and path.endswith(".json"))
|
||||
if manifest["upstream"] != version or manifest.get("upstream_commit") != lock["commit"] or sorted(manifest["themes"]) != names or sorted(manifest["languages"]) != languages:
|
||||
raise ValueError("Preview does not match lock/all source themes and languages; run preview all")
|
||||
files["preview/rendered.js"] = manifest_data
|
||||
for lang in languages:
|
||||
data = read_file(root / "preview" / "rendered" / (lang + ".js"))
|
||||
marker = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(lang) + '] = ').encode()
|
||||
payload = json.loads(data.split(marker, 1)[1].strip().removesuffix(b";"))
|
||||
if sorted(payload) != names or any(set(payload[name]) != set(manifest["registry"]) for name in names):
|
||||
raise ValueError(f"Incomplete preview language bundle: {lang}")
|
||||
files[f"preview/rendered/{lang}.js"] = data
|
||||
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]:
|
||||
files[name] = read_file(root / name)
|
||||
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
|
||||
manifest = parse_script_payload(manifest_data, PREVIEW_MANIFEST_PREFIX)
|
||||
languages = sorted(
|
||||
path.removeprefix("options/locale/locale_").removesuffix(".json")
|
||||
for path in lock["files"]
|
||||
if path.startswith("options/locale/locale_") and path.endswith(".json")
|
||||
)
|
||||
matches_build = (
|
||||
manifest["upstream"] == lock["tag"]
|
||||
and manifest.get("upstream_commit") == lock["commit"]
|
||||
and sorted(manifest["themes"]) == theme_names
|
||||
and sorted(manifest["languages"]) == languages
|
||||
)
|
||||
if not matches_build:
|
||||
raise ValueError(
|
||||
"Preview does not match lock/all source themes and languages; run preview all"
|
||||
)
|
||||
files = {"preview/rendered.js": manifest_data}
|
||||
expected_templates = set(manifest["registry"])
|
||||
for language in languages:
|
||||
data = read_file(root / "preview" / "rendered" / (language + ".js"))
|
||||
prefix = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(language) + '] = ').encode()
|
||||
payload = parse_script_payload(data, prefix)
|
||||
if sorted(payload) != theme_names or any(
|
||||
set(payload[name]) != expected_templates for name in theme_names
|
||||
):
|
||||
raise ValueError(f"Incomplete preview language bundle: {language}")
|
||||
files[f"preview/rendered/{language}.js"] = data
|
||||
return files
|
||||
|
||||
|
||||
def collect_documentation(root):
|
||||
files = {name: read_file(root / name) for name in REQUIRED_FILES}
|
||||
for name in OPTIONAL_ROOT_DOCUMENTS:
|
||||
if (root / name).exists():
|
||||
files[name] = read_file(root / name)
|
||||
for directory in [root / "docs", root / ".github" / "release-notes"]:
|
||||
for path in sorted(directory.rglob("*.md")):
|
||||
for directory in DOCUMENTATION_DIRECTORIES:
|
||||
for path in sorted((root / directory).rglob("*.md")):
|
||||
name = path.relative_to(root).as_posix()
|
||||
files[name] = read_file(checked_path(root, name))
|
||||
for path in sorted((root / "docs" / "images").iterdir()):
|
||||
if path.is_file() and (path.suffix == ".png" or path.name == "README.md"):
|
||||
files["docs/images/" + path.name] = read_file(path)
|
||||
return files
|
||||
|
||||
|
||||
def collect_provenance(root, lock):
|
||||
license_data = read_file(root / "build" / "upstream" / "LICENSE")
|
||||
if digest(license_data) != lock["files"]["LICENSE"]:
|
||||
raise ValueError("Upstream license checksum mismatch")
|
||||
files["GITEA-LICENSE"] = license_data
|
||||
files["upstream-lock.json"] = read_file(root / "gitea.lock.json")
|
||||
return {
|
||||
"GITEA-LICENSE": license_data,
|
||||
"upstream-lock.json": read_file(root / "gitea.lock.json"),
|
||||
}
|
||||
|
||||
|
||||
def collect(root, version):
|
||||
"""Collect verified release content without writing any archives."""
|
||||
root = Path(root)
|
||||
lock = load_lock(root, version)
|
||||
theme_names = discover_theme_names(root)
|
||||
files = {}
|
||||
for name in theme_names:
|
||||
files.update(collect_theme(root, lock, name))
|
||||
files.update(collect_preview(root, lock, theme_names))
|
||||
files.update(collect_documentation(root))
|
||||
files.update(collect_provenance(root, lock))
|
||||
return files
|
||||
|
||||
|
||||
def package(root, version, output):
|
||||
"""Write both archive formats from the same verified file collection."""
|
||||
files = collect(root, version)
|
||||
archive = f"gitea-mail-templates-{version}"
|
||||
output = Path(output)
|
||||
targets = [output / (archive + ext) for ext in (".zip", ".tar.gz")]
|
||||
targets = [output / (archive + ext) for ext in ARCHIVE_EXTENSIONS]
|
||||
if any(path.exists() for path in targets):
|
||||
raise ValueError("Refusing to overwrite existing release archives; use a new output directory")
|
||||
raise ValueError(
|
||||
"Refusing to overwrite existing release archives; use a new output directory"
|
||||
)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(targets[0], "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zipped, tarfile.open(targets[1], "w:gz") as tar:
|
||||
with (
|
||||
zipfile.ZipFile(targets[0], "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zipped,
|
||||
tarfile.open(targets[1], "w:gz") as tar,
|
||||
):
|
||||
for name, data in sorted(files.items()):
|
||||
path = archive + "/" + name
|
||||
zipped.writestr(path, data)
|
||||
info = tarfile.TarInfo(path)
|
||||
info.size, info.mode = len(data), 0o644
|
||||
info.size = len(data)
|
||||
info.mode = 0o644
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
||||
@@ -135,3 +239,7 @@ if __name__ == "__main__":
|
||||
package(args.root, args.version, args.output)
|
||||
except (ValueError, KeyError, IndexError, OSError) as error:
|
||||
parser.exit(1, f"[FAIL] {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Offline Gitea API and issue-reminder regression tests."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("gitea_actions", Path(__file__).with_name("gitea_actions.py"))
|
||||
ACTIONS = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(ACTIONS)
|
||||
|
||||
|
||||
class APITests(unittest.TestCase):
|
||||
def test_instance_url_token_and_raw_asset_request(self):
|
||||
api = ACTIONS.GiteaAPI("https://git.example/subpath/", "owner/repo", "test-token")
|
||||
response = Mock()
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
response.read.return_value = b'{"id": 1}'
|
||||
api.opener = Mock()
|
||||
api.opener.open.return_value = response
|
||||
self.assertEqual({"id": 1}, api.request("POST", "/releases/1/assets?name=test.zip", b"archive", "application/octet-stream"))
|
||||
request = api.opener.open.call_args.args[0]
|
||||
self.assertEqual("https://git.example/subpath/api/v1/repos/owner/repo/releases/1/assets?name=test.zip", request.full_url)
|
||||
self.assertEqual("token test-token", request.get_header("Authorization"))
|
||||
self.assertEqual(b"archive", request.data)
|
||||
self.assertEqual("application/octet-stream", request.get_header("Content-type"))
|
||||
|
||||
def test_only_explicit_404_is_missing_and_redirects_are_refused(self):
|
||||
api = ACTIONS.GiteaAPI("https://git.example", "owner/repo", "test-token")
|
||||
api.opener = Mock()
|
||||
for status in (401, 403, 500, 302):
|
||||
api.opener.open.side_effect = HTTPError(api.base, status, "error", {}, None)
|
||||
with self.assertRaisesRegex(RuntimeError, f"HTTP {status}"):
|
||||
api.request("GET", "/releases/tags/v28.0.0", allow_missing=True)
|
||||
api.opener.open.side_effect = HTTPError(api.base, 404, "missing", {}, None)
|
||||
self.assertIsNone(api.request("GET", "/releases/tags/v28.0.0", allow_missing=True))
|
||||
self.assertIsNone(ACTIONS.NoRedirects().redirect_request(None, None, 302, "", {}, "https://elsewhere.example"))
|
||||
|
||||
def test_rejects_invalid_configuration(self):
|
||||
for server, repository, token in [("http://git.example", "owner/repo", "x"),
|
||||
("https://user:password@git.example", "owner/repo", "x"),
|
||||
("https://git.example", "../owner/repo", "x"), ("https://git.example", "owner/repo", "")]:
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.GiteaAPI(server, repository, token)
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "gitea.lock.json").write_text(json.dumps({"tag": "v28.0.0"}))
|
||||
notes = self.root / ".github/release-notes/v28.0.0.md"
|
||||
notes.parent.mkdir(parents=True)
|
||||
notes.write_text("Reviewed notes", encoding="utf-8")
|
||||
(self.root / "dist").mkdir()
|
||||
for ext in (".zip", ".tar.gz"):
|
||||
(self.root / "dist" / ("gitea-mail-templates-v28.0.0" + ext)).write_bytes(b"archive")
|
||||
|
||||
def test_existing_release_is_not_modified(self):
|
||||
api = Mock()
|
||||
api.request.return_value = {"id": 5}
|
||||
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
self.assertEqual(["GET"], [call.args[0] for call in api.request.call_args_list])
|
||||
|
||||
def test_publish_only_after_both_uploads_succeed(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [None, {"id": 5}, {"id": 6}, {"id": 7}, {"html_url": "https://git.example/release"}]
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
calls = api.request.call_args_list
|
||||
self.assertEqual(["GET", "POST", "POST", "POST", "PATCH"], [c.args[0] for c in calls])
|
||||
self.assertTrue(calls[1].args[2]["draft"])
|
||||
self.assertEqual("Reviewed notes", calls[1].args[2]["body"])
|
||||
self.assertEqual("/releases/5/assets?name=gitea-mail-templates-v28.0.0.zip", calls[2].args[1])
|
||||
self.assertEqual("/releases/5/assets?name=gitea-mail-templates-v28.0.0.tar.gz", calls[3].args[1])
|
||||
self.assertEqual({"draft": False}, calls[4].args[2])
|
||||
|
||||
def test_failed_upload_leaves_draft_unpublished(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [None, {"id": 5}, RuntimeError("upload failed")]
|
||||
with self.assertRaisesRegex(RuntimeError, "upload failed"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
self.assertNotIn("PATCH", [c.args[0] for c in api.request.call_args_list])
|
||||
|
||||
def test_bad_version_or_missing_archive_fails_before_api_call(self):
|
||||
api = Mock()
|
||||
for version in ("v28.0.1", "v28.0.0-rc1", "../../x"):
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.publish_release(api, version, self.root)
|
||||
(self.root / "dist/gitea-mail-templates-v28.0.0.zip").unlink()
|
||||
with self.assertRaisesRegex(ValueError, "archive"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
api.request.assert_not_called()
|
||||
|
||||
|
||||
class IssueTests(unittest.TestCase):
|
||||
def test_creates_release_documentation_issue(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [[], {"html_url": "https://git.example/issues/1"}]
|
||||
ACTIONS.create_issue(api, "v28.1.0")
|
||||
calls = api.request.call_args_list
|
||||
self.assertEqual(["GET", "POST"], [c.args[0] for c in calls])
|
||||
self.assertEqual("/issues", calls[-1].args[1])
|
||||
payload = calls[-1].args[2]
|
||||
self.assertIn("<!-- gitea-mail-templates:template-release:v28.1.0 -->", payload["body"])
|
||||
self.assertIn("Documentation is maintained manually", payload["body"])
|
||||
|
||||
def test_reuses_closed_issue_on_later_page_and_ignores_pull_requests(self):
|
||||
api = Mock()
|
||||
marker = "<!-- gitea-mail-templates:template-release:v28.1.0 -->"
|
||||
api.request.side_effect = [[{"body": marker, "pull_request": {"url": "pr"}}],
|
||||
[{"body": marker, "state": "closed", "title": "Renamed", "html_url": "https://git.example/issues/1"}]]
|
||||
ACTIONS.create_issue(api, "v28.1.0")
|
||||
self.assertEqual(["GET", "GET"], [c.args[0] for c in api.request.call_args_list])
|
||||
self.assertIn("state=all", api.request.call_args_list[0].args[1])
|
||||
self.assertIn("page=2", api.request.call_args_list[1].args[1])
|
||||
|
||||
def test_other_release_versions_do_not_suppress_reminder(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [[{"body": "<!-- gitea-mail-templates:template-release:v28.0.0 -->"}], [],
|
||||
{"html_url": "https://git.example/issues/2"}]
|
||||
ACTIONS.create_issue(api, "v28.1.0")
|
||||
payload = api.request.call_args.args[2]
|
||||
self.assertIn("template-release:v28.1.0", payload["body"])
|
||||
self.assertIn("READMEs", payload["body"])
|
||||
|
||||
def test_invalid_versions_fail_before_api_call(self):
|
||||
api = Mock()
|
||||
for version in ("28.1.0", "v28.1.0-rc1", "../../x"):
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.create_issue(api, version)
|
||||
api.request.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,97 +1,196 @@
|
||||
"""Offline artifact/provenance tests independent of generated repository output."""
|
||||
"""Offline packaging tests using a minimal, independently generated repository."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("package_release", Path(__file__).with_name("package_release.py"))
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("package_release.py")
|
||||
SPEC = importlib.util.spec_from_file_location("package_release", SCRIPT_PATH)
|
||||
PACKER = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(PACKER)
|
||||
|
||||
|
||||
class PackagingTests(unittest.TestCase):
|
||||
VERSION = "v28.0.0"
|
||||
COMMIT = "a" * 40
|
||||
THEME = "demo"
|
||||
LANGUAGE = "en-US"
|
||||
ROOT_DOCUMENTS = (
|
||||
"LICENSE",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"CONTRIBUTING.md",
|
||||
"COMPATIBILITY.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"docs/README.zh-CN.md",
|
||||
"docs/CONTRIBUTING.zh-CN.md",
|
||||
"preview/index.html",
|
||||
"docs/images/README.md",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.tag = "v28.0.0"
|
||||
self.commit = "a" * 40
|
||||
theme = json.dumps({"name": "demo", "mode": "shared"}).encode()
|
||||
css = b"a { color:blue; }"
|
||||
self.write("themes/demo/theme.json", theme)
|
||||
self.write("themes/demo/theme.css", css)
|
||||
files = {"mail/base/head.tmpl": b"<title>test</title>", "mail/base/footer.tmpl": b"<p>footer</p>"}
|
||||
meta = {"theme": "demo", "mode": "shared", "gitea_tag": self.tag, "gitea_commit": self.commit, "sources": {"theme.json": PACKER.digest(theme), "theme.css": PACKER.digest(css)}, "files": {p: PACKER.digest(data) for p, data in files.items()}}
|
||||
for path, data in files.items():
|
||||
self.write("build/themes/demo/" + path, data)
|
||||
self.write_json("build/themes/demo/build.json", meta)
|
||||
self.write_json("gitea.lock.json", {"tag": self.tag, "commit": self.commit, "files": {"LICENSE": PACKER.digest(b"MIT"), "options/locale/locale_en-US.json": PACKER.digest(b"{}")}})
|
||||
self.write("build/upstream/LICENSE", b"MIT")
|
||||
manifest = {"upstream": self.tag, "upstream_commit": self.commit, "themes": ["demo"], "languages": ["en-US"], "registry": {"activate": {}}}
|
||||
self.write("preview/rendered.js", ("window.__MAIL_PREVIEW__ = " + json.dumps(manifest) + ";").encode())
|
||||
self.write("preview/rendered/en-US.js", ('window.__MAIL_PREVIEW_DATA__["en-US"] = ' + json.dumps({"demo": {"activate": "<html></html>"}}) + ";").encode())
|
||||
for name in ["LICENSE", "AGENTS.md", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html", "docs/images/README.md"]:
|
||||
temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
self.root = Path(temporary_directory.name)
|
||||
self.output = self.root / "artifacts"
|
||||
self.create_theme()
|
||||
self.create_snapshot()
|
||||
self.create_preview()
|
||||
for name in self.ROOT_DOCUMENTS:
|
||||
self.write(name, b"test")
|
||||
|
||||
# Fixture helpers keep test bodies focused on the behavior being checked.
|
||||
def write(self, name, data):
|
||||
path = self.root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
def write_json(self, name, data):
|
||||
self.write(name, json.dumps(data).encode())
|
||||
self.write(name, json.dumps(data).encode("utf-8"))
|
||||
|
||||
def test_packages_declared_outputs_only_and_never_overwrites(self):
|
||||
def create_theme(self):
|
||||
theme_data = json.dumps({"name": self.THEME, "mode": "shared"}).encode("utf-8")
|
||||
css_data = b"a { color:blue; }"
|
||||
self.write("themes/demo/theme.json", theme_data)
|
||||
self.write("themes/demo/theme.css", css_data)
|
||||
|
||||
generated_files = {
|
||||
"mail/base/head.tmpl": b"<title>test</title>",
|
||||
"mail/base/footer.tmpl": b"<p>footer</p>",
|
||||
}
|
||||
metadata = {
|
||||
"theme": self.THEME,
|
||||
"mode": "shared",
|
||||
"gitea_tag": self.VERSION,
|
||||
"gitea_commit": self.COMMIT,
|
||||
"sources": {
|
||||
"theme.json": PACKER.digest(theme_data),
|
||||
"theme.css": PACKER.digest(css_data),
|
||||
},
|
||||
"files": {
|
||||
path: PACKER.digest(data)
|
||||
for path, data in generated_files.items()
|
||||
},
|
||||
}
|
||||
for path, data in generated_files.items():
|
||||
self.write("build/themes/demo/" + path, data)
|
||||
self.write_json("build/themes/demo/build.json", metadata)
|
||||
|
||||
def create_snapshot(self):
|
||||
self.write_json("gitea.lock.json", {
|
||||
"tag": self.VERSION,
|
||||
"commit": self.COMMIT,
|
||||
"files": {
|
||||
"LICENSE": PACKER.digest(b"MIT"),
|
||||
"options/locale/locale_en-US.json": PACKER.digest(b"{}"),
|
||||
},
|
||||
})
|
||||
self.write("build/upstream/LICENSE", b"MIT")
|
||||
|
||||
def create_preview(self):
|
||||
manifest = {
|
||||
"upstream": self.VERSION,
|
||||
"upstream_commit": self.COMMIT,
|
||||
"themes": [self.THEME],
|
||||
"languages": [self.LANGUAGE],
|
||||
"registry": {"activate": {}},
|
||||
}
|
||||
bundle = {self.THEME: {"activate": "<html></html>"}}
|
||||
self.write("preview/rendered.js", (
|
||||
"window.__MAIL_PREVIEW__ = " + json.dumps(manifest) + ";"
|
||||
).encode("utf-8"))
|
||||
self.write("preview/rendered/en-US.js", (
|
||||
'window.__MAIL_PREVIEW_DATA__["en-US"] = ' + json.dumps(bundle) + ";"
|
||||
).encode("utf-8"))
|
||||
|
||||
# Archives: both formats contain the verified bytes, never stale build files.
|
||||
def test_archives_contain_only_verified_outputs(self):
|
||||
self.write("build/themes/stale/mail/stale.tmpl", b"stale")
|
||||
out = self.root / "artifacts"
|
||||
PACKER.package(self.root, self.tag, out)
|
||||
with zipfile.ZipFile(next(out.glob("*.zip"))) as archive:
|
||||
names = archive.namelist()
|
||||
self.assertTrue(any(name.endswith("themes/demo/mail/base/head.tmpl") for name in names))
|
||||
self.assertFalse(any("stale" in name or "/build/" in name for name in names))
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.package(self.root, self.tag, out)
|
||||
expected_files = PACKER.collect(self.root, self.VERSION)
|
||||
prefix = f"gitea-mail-templates-{self.VERSION}/"
|
||||
expected_members = {
|
||||
prefix + name: data for name, data in expected_files.items()
|
||||
}
|
||||
|
||||
def test_refuses_stale_source_tampered_output_and_wrong_version(self):
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.package(self.root, self.VERSION, self.output)
|
||||
|
||||
with zipfile.ZipFile(self.output / f"gitea-mail-templates-{self.VERSION}.zip") as archive:
|
||||
zipped = {name: archive.read(name) for name in archive.namelist()}
|
||||
with tarfile.open(self.output / f"gitea-mail-templates-{self.VERSION}.tar.gz") as archive:
|
||||
tarred = {
|
||||
member.name: archive.extractfile(member).read()
|
||||
for member in archive.getmembers()
|
||||
}
|
||||
self.assertTrue(all(member.mode == 0o644 for member in archive.getmembers()))
|
||||
self.assertEqual(expected_members, zipped)
|
||||
self.assertEqual(expected_members, tarred)
|
||||
self.assertIn(prefix + "themes/demo/mail/base/head.tmpl", zipped)
|
||||
self.assertFalse(any("stale" in name or "/build/" in name for name in zipped))
|
||||
|
||||
def test_existing_archives_are_not_overwritten(self):
|
||||
PACKER.package(self.root, self.VERSION, self.output)
|
||||
original_archives = {
|
||||
path.name: path.read_bytes() for path in self.output.iterdir()
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Refusing to overwrite"):
|
||||
PACKER.package(self.root, self.VERSION, self.output)
|
||||
|
||||
self.assertEqual(original_archives, {
|
||||
path.name: path.read_bytes() for path in self.output.iterdir()
|
||||
})
|
||||
|
||||
# Validation: release identity, source checksums and generated checksums.
|
||||
def test_wrong_version_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "must match the locked Gitea tag"):
|
||||
PACKER.collect(self.root, "v28.1.0")
|
||||
|
||||
def test_changed_source_is_rejected(self):
|
||||
self.write("themes/demo/theme.css", b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "Source changed"):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
def test_refuses_missing_language_bundle(self):
|
||||
def test_missing_language_bundle_is_rejected(self):
|
||||
(self.root / "preview/rendered/en-US.js").unlink()
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
with self.assertRaisesRegex(ValueError, "Expected regular file"):
|
||||
PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
def test_refuses_modified_generated_files(self):
|
||||
def test_modified_generated_files_are_rejected(self):
|
||||
self.write("build/themes/demo/mail/base/head.tmpl", b"modified")
|
||||
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
def test_includes_nested_documentation_and_release_note_sources(self):
|
||||
# Documentation: recursive inclusion and optional root files.
|
||||
def test_nested_documentation_and_release_notes_are_included(self):
|
||||
documents = {
|
||||
"docs/INDEX.zh-CN.md": "中文索引".encode(),
|
||||
"docs/images/README.zh-CN.md": "截图指南".encode(),
|
||||
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode(),
|
||||
"docs/INDEX.zh-CN.md": "中文索引".encode("utf-8"),
|
||||
"docs/images/README.zh-CN.md": "截图指南".encode("utf-8"),
|
||||
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode("utf-8"),
|
||||
".github/release-notes/v28.0.0.md": b"English release notes",
|
||||
}
|
||||
for name, data in documents.items():
|
||||
self.write(name, data)
|
||||
self.write("docs/private.tmp", b"not documentation")
|
||||
files = PACKER.collect(self.root, self.tag)
|
||||
|
||||
files = PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
for name, data in documents.items():
|
||||
self.assertEqual(files[name], data)
|
||||
with self.subTest(document=name):
|
||||
self.assertEqual(data, files[name])
|
||||
self.assertIn("AGENTS.md", files)
|
||||
self.assertNotIn("docs/private.tmp", files)
|
||||
|
||||
def test_packages_without_removed_optional_root_documents(self):
|
||||
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
|
||||
def test_optional_root_documents_may_be_absent(self):
|
||||
for name in ("AGENTS.md", "THIRD_PARTY_NOTICES.md"):
|
||||
(self.root / name).unlink()
|
||||
files = PACKER.collect(self.root, self.tag)
|
||||
|
||||
files = PACKER.collect(self.root, self.VERSION)
|
||||
|
||||
self.assertNotIn("AGENTS.md", files)
|
||||
self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
|
||||
self.assertIn("GITEA-LICENSE", files)
|
||||
|
||||
@@ -2,44 +2,17 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Templates
|
||||
runs-on: linux-amd64-docker-small
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Test workflow scripts
|
||||
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
- name: Verify pinned official snapshot
|
||||
working-directory: tools
|
||||
run: |
|
||||
go run . upstream prepare
|
||||
go run . upstream verify
|
||||
|
||||
- name: Test — framework alignment and multilingual notification parity
|
||||
working-directory: tools
|
||||
run: go test ./...
|
||||
|
||||
- name: Build and render all themes and official languages
|
||||
working-directory: tools
|
||||
run: go run . preview all
|
||||
name: Validate
|
||||
uses: ./.github/workflows/validate.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
package:
|
||||
name: Package & Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Package
|
||||
needs: validate
|
||||
runs-on: linux-amd64-docker-small
|
||||
permissions:
|
||||
@@ -57,50 +30,44 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify release tag matches the official snapshot
|
||||
- name: Verify tag matches the snapshot
|
||||
run: |
|
||||
python -c 'import json, os; lock=json.load(open("gitea.lock.json")); assert lock["tag"] == os.environ["GITHUB_REF_NAME"], "Release tag must match the pinned Gitea tag"'
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
- name: Verify release notes
|
||||
run: test -s ".github/release-notes/${GITHUB_REF_NAME}.md"
|
||||
with open("gitea.lock.json", encoding="utf-8") as lock_file:
|
||||
lock = json.load(lock_file)
|
||||
assert lock["tag"] == os.environ["GITHUB_REF_NAME"], (
|
||||
"Release tag must match the pinned Gitea tag"
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Generate static preview for the archive
|
||||
- name: Generate preview
|
||||
working-directory: tools
|
||||
run: go run . preview all
|
||||
|
||||
- name: Package generated installable themes and multilingual preview
|
||||
- name: Package themes and preview
|
||||
run: python -B .github/scripts/package_release.py --version "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
|
||||
- name: Create Release
|
||||
run: python -B .github/scripts/gitea_actions.py publish-release --version "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
notify-release-docs:
|
||||
name: Remind to Update Release Documentation
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: package
|
||||
concurrency:
|
||||
group: release-documentation-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
runs-on: linux-amd64-docker-small
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- name: Prepare release runtime
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Create documentation reminder Issue
|
||||
run: python -B .github/scripts/gitea_actions.py create-issue --version "$TEMPLATE_RELEASE"
|
||||
env:
|
||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
node-version: '22'
|
||||
|
||||
- name: Release
|
||||
uses: actions/gitea-release-action@v1.3.7
|
||||
with:
|
||||
server_url: ${{ github.server_url }}
|
||||
repository: ${{ github.repository }}
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
body: ''
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
dist/gitea-mail-templates-${{ github.ref_name }}.zip
|
||||
dist/gitea-mail-templates-${{ github.ref_name }}.tar.gz
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: linux-amd64-docker-small
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Test workflow scripts
|
||||
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
|
||||
|
||||
- name: Verify snapshot
|
||||
working-directory: tools
|
||||
run: |
|
||||
go run . upstream prepare
|
||||
go run . upstream verify
|
||||
|
||||
- name: Tools test
|
||||
working-directory: tools
|
||||
run: go test ./...
|
||||
|
||||
- name: Build and render all themes and official languages
|
||||
working-directory: tools
|
||||
run: go run . preview all
|
||||
Reference in new issue
Block a user