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 hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from pathlib import Path, PurePosixPath
|
|
||||||
import re
|
import re
|
||||||
import tarfile
|
import tarfile
|
||||||
import zipfile
|
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):
|
def read_file(path):
|
||||||
@@ -31,101 +49,187 @@ def digest(data):
|
|||||||
return hashlib.sha256(data).hexdigest()
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def collect(root, version):
|
def read_json(path):
|
||||||
root = Path(root)
|
return json.loads(read_file(path))
|
||||||
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version):
|
|
||||||
|
|
||||||
|
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")
|
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"]:
|
if version != lock["tag"]:
|
||||||
raise ValueError("Package version must match the locked Gitea tag")
|
raise ValueError("Package version must match the locked Gitea tag")
|
||||||
files = {}
|
return lock
|
||||||
names = sorted(p.name for p in (root / "themes").iterdir() if p.is_dir() and not p.is_symlink())
|
|
||||||
|
|
||||||
|
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:
|
if not names:
|
||||||
raise ValueError("No source theme manifests")
|
raise ValueError("No source theme manifests")
|
||||||
for name in names:
|
return names
|
||||||
if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
|
|
||||||
raise ValueError(f"Invalid theme name: {name}")
|
|
||||||
source = root / "themes" / name
|
def expected_template_paths(root, lock, mode):
|
||||||
theme = json.loads(read_file(source / "theme.json"))
|
"""Shared themes override base files; framed themes also supply all bodies."""
|
||||||
if theme["name"] != name or theme["mode"] not in ("shared", "framed"):
|
paths = set(BASE_TEMPLATES)
|
||||||
raise ValueError(f"Invalid theme metadata: {name}")
|
if mode == "framed":
|
||||||
built = root / "build" / "themes" / name
|
paths.update(
|
||||||
meta = json.loads(read_file(built / "build.json"))
|
path.removeprefix("templates/")
|
||||||
if (meta["theme"], meta["mode"], meta["gitea_tag"], meta["gitea_commit"]) != (name, theme["mode"], version, lock["commit"]):
|
for path in lock["files"]
|
||||||
raise ValueError(f"Stale build identity: {name}")
|
if path.startswith("templates/mail/") and path.endswith(".tmpl")
|
||||||
if not {"theme.json", "theme.css"}.issubset(meta.get("sources") or {}) or not meta.get("files"):
|
)
|
||||||
raise ValueError(f"Missing source/file provenance: {name}")
|
paths.update(
|
||||||
expected_files = {"mail/base/head.tmpl", "mail/base/footer.tmpl"}
|
path.relative_to(root / "framework").as_posix()
|
||||||
if theme["mode"] == "framed":
|
for path in (root / "framework" / "mail").rglob("*.tmpl")
|
||||||
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"))
|
return paths
|
||||||
if set(meta["files"]) != expected_files:
|
|
||||||
raise ValueError(f"Incomplete generated install package: {name}")
|
|
||||||
for path, expected in meta["sources"].items():
|
def collect_theme(root, lock, name):
|
||||||
base = root if path.startswith(("framework/", "tools/")) else source
|
"""Accept only a complete build whose identity and checksums still match."""
|
||||||
if digest(read_file(checked_path(base, path))) != expected:
|
if not THEME_NAME_PATTERN.fullmatch(name):
|
||||||
raise ValueError(f"Source changed since build: {name}/{path}")
|
raise ValueError(f"Invalid theme name: {name}")
|
||||||
for path, expected in meta["files"].items():
|
source = root / "themes" / name
|
||||||
if not path.startswith("mail/") or not path.endswith(".tmpl"):
|
theme = read_json(source / "theme.json")
|
||||||
raise ValueError(f"Unexpected generated file: {path}")
|
if theme["name"] != name or theme["mode"] not in ("shared", "framed"):
|
||||||
data = read_file(checked_path(built, path))
|
raise ValueError(f"Invalid theme metadata: {name}")
|
||||||
if digest(data) != expected:
|
built = root / "build" / "themes" / name
|
||||||
raise ValueError(f"Generated checksum mismatch: {name}/{path}")
|
metadata = read_json(built / "build.json")
|
||||||
files[f"themes/{name}/{path}"] = data
|
actual_identity = (
|
||||||
files[f"themes/{name}/build.json"] = read_file(built / "build.json")
|
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")
|
manifest_data = read_file(root / "preview" / "rendered.js")
|
||||||
prefix = b"window.__MAIL_PREVIEW__ = "
|
manifest = parse_script_payload(manifest_data, PREVIEW_MANIFEST_PREFIX)
|
||||||
manifest = json.loads(manifest_data.split(prefix, 1)[1].strip().removesuffix(b";"))
|
languages = sorted(
|
||||||
languages = sorted(path.removeprefix("options/locale/locale_").removesuffix(".json") for path in lock["files"] if path.startswith("options/locale/locale_") and path.endswith(".json"))
|
path.removeprefix("options/locale/locale_").removesuffix(".json")
|
||||||
if manifest["upstream"] != version or manifest.get("upstream_commit") != lock["commit"] or sorted(manifest["themes"]) != names or sorted(manifest["languages"]) != languages:
|
for path in lock["files"]
|
||||||
raise ValueError("Preview does not match lock/all source themes and languages; run preview all")
|
if path.startswith("options/locale/locale_") and path.endswith(".json")
|
||||||
files["preview/rendered.js"] = manifest_data
|
)
|
||||||
for lang in languages:
|
matches_build = (
|
||||||
data = read_file(root / "preview" / "rendered" / (lang + ".js"))
|
manifest["upstream"] == lock["tag"]
|
||||||
marker = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(lang) + '] = ').encode()
|
and manifest.get("upstream_commit") == lock["commit"]
|
||||||
payload = json.loads(data.split(marker, 1)[1].strip().removesuffix(b";"))
|
and sorted(manifest["themes"]) == theme_names
|
||||||
if sorted(payload) != names or any(set(payload[name]) != set(manifest["registry"]) for name in names):
|
and sorted(manifest["languages"]) == languages
|
||||||
raise ValueError(f"Incomplete preview language bundle: {lang}")
|
)
|
||||||
files[f"preview/rendered/{lang}.js"] = data
|
if not matches_build:
|
||||||
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]:
|
raise ValueError(
|
||||||
files[name] = read_file(root / name)
|
"Preview does not match lock/all source themes and languages; run preview all"
|
||||||
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
|
)
|
||||||
|
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():
|
if (root / name).exists():
|
||||||
files[name] = read_file(root / name)
|
files[name] = read_file(root / name)
|
||||||
for directory in [root / "docs", root / ".github" / "release-notes"]:
|
for directory in DOCUMENTATION_DIRECTORIES:
|
||||||
for path in sorted(directory.rglob("*.md")):
|
for path in sorted((root / directory).rglob("*.md")):
|
||||||
name = path.relative_to(root).as_posix()
|
name = path.relative_to(root).as_posix()
|
||||||
files[name] = read_file(checked_path(root, name))
|
files[name] = read_file(checked_path(root, name))
|
||||||
for path in sorted((root / "docs" / "images").iterdir()):
|
for path in sorted((root / "docs" / "images").iterdir()):
|
||||||
if path.is_file() and (path.suffix == ".png" or path.name == "README.md"):
|
if path.is_file() and (path.suffix == ".png" or path.name == "README.md"):
|
||||||
files["docs/images/" + path.name] = read_file(path)
|
files["docs/images/" + path.name] = read_file(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def collect_provenance(root, lock):
|
||||||
license_data = read_file(root / "build" / "upstream" / "LICENSE")
|
license_data = read_file(root / "build" / "upstream" / "LICENSE")
|
||||||
if digest(license_data) != lock["files"]["LICENSE"]:
|
if digest(license_data) != lock["files"]["LICENSE"]:
|
||||||
raise ValueError("Upstream license checksum mismatch")
|
raise ValueError("Upstream license checksum mismatch")
|
||||||
files["GITEA-LICENSE"] = license_data
|
return {
|
||||||
files["upstream-lock.json"] = read_file(root / "gitea.lock.json")
|
"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
|
return files
|
||||||
|
|
||||||
|
|
||||||
def package(root, version, output):
|
def package(root, version, output):
|
||||||
|
"""Write both archive formats from the same verified file collection."""
|
||||||
files = collect(root, version)
|
files = collect(root, version)
|
||||||
archive = f"gitea-mail-templates-{version}"
|
archive = f"gitea-mail-templates-{version}"
|
||||||
output = Path(output)
|
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):
|
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)
|
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()):
|
for name, data in sorted(files.items()):
|
||||||
path = archive + "/" + name
|
path = archive + "/" + name
|
||||||
zipped.writestr(path, data)
|
zipped.writestr(path, data)
|
||||||
info = tarfile.TarInfo(path)
|
info = tarfile.TarInfo(path)
|
||||||
info.size, info.mode = len(data), 0o644
|
info.size = len(data)
|
||||||
|
info.mode = 0o644
|
||||||
tar.addfile(info, io.BytesIO(data))
|
tar.addfile(info, io.BytesIO(data))
|
||||||
print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}")
|
print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--version", required=True)
|
parser.add_argument("--version", required=True)
|
||||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
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)
|
package(args.root, args.version, args.output)
|
||||||
except (ValueError, KeyError, IndexError, OSError) as error:
|
except (ValueError, KeyError, IndexError, OSError) as error:
|
||||||
parser.exit(1, f"[FAIL] {error}\n")
|
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 importlib.util
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import zipfile
|
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)
|
PACKER = importlib.util.module_from_spec(SPEC)
|
||||||
SPEC.loader.exec_module(PACKER)
|
SPEC.loader.exec_module(PACKER)
|
||||||
|
|
||||||
|
|
||||||
class PackagingTests(unittest.TestCase):
|
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):
|
def setUp(self):
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
temporary_directory = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self.temp.cleanup)
|
self.addCleanup(temporary_directory.cleanup)
|
||||||
self.root = Path(self.temp.name)
|
self.root = Path(temporary_directory.name)
|
||||||
self.tag = "v28.0.0"
|
self.output = self.root / "artifacts"
|
||||||
self.commit = "a" * 40
|
self.create_theme()
|
||||||
theme = json.dumps({"name": "demo", "mode": "shared"}).encode()
|
self.create_snapshot()
|
||||||
css = b"a { color:blue; }"
|
self.create_preview()
|
||||||
self.write("themes/demo/theme.json", theme)
|
for name in self.ROOT_DOCUMENTS:
|
||||||
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"]:
|
|
||||||
self.write(name, b"test")
|
self.write(name, b"test")
|
||||||
|
|
||||||
|
# Fixture helpers keep test bodies focused on the behavior being checked.
|
||||||
def write(self, name, data):
|
def write(self, name, data):
|
||||||
path = self.root / name
|
path = self.root / name
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_bytes(data)
|
path.write_bytes(data)
|
||||||
|
|
||||||
def write_json(self, name, 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")
|
self.write("build/themes/stale/mail/stale.tmpl", b"stale")
|
||||||
out = self.root / "artifacts"
|
expected_files = PACKER.collect(self.root, self.VERSION)
|
||||||
PACKER.package(self.root, self.tag, out)
|
prefix = f"gitea-mail-templates-{self.VERSION}/"
|
||||||
with zipfile.ZipFile(next(out.glob("*.zip"))) as archive:
|
expected_members = {
|
||||||
names = archive.namelist()
|
prefix + name: data for name, data in expected_files.items()
|
||||||
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)
|
|
||||||
|
|
||||||
def test_refuses_stale_source_tampered_output_and_wrong_version(self):
|
PACKER.package(self.root, self.VERSION, self.output)
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
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")
|
PACKER.collect(self.root, "v28.1.0")
|
||||||
|
|
||||||
|
def test_changed_source_is_rejected(self):
|
||||||
self.write("themes/demo/theme.css", b"changed")
|
self.write("themes/demo/theme.css", b"changed")
|
||||||
with self.assertRaisesRegex(ValueError, "Source 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()
|
(self.root / "preview/rendered/en-US.js").unlink()
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaisesRegex(ValueError, "Expected regular file"):
|
||||||
PACKER.collect(self.root, self.tag)
|
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")
|
self.write("build/themes/demo/mail/base/head.tmpl", b"modified")
|
||||||
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
|
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 = {
|
documents = {
|
||||||
"docs/INDEX.zh-CN.md": "中文索引".encode(),
|
"docs/INDEX.zh-CN.md": "中文索引".encode("utf-8"),
|
||||||
"docs/images/README.zh-CN.md": "截图指南".encode(),
|
"docs/images/README.zh-CN.md": "截图指南".encode("utf-8"),
|
||||||
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode(),
|
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode("utf-8"),
|
||||||
".github/release-notes/v28.0.0.md": b"English release notes",
|
".github/release-notes/v28.0.0.md": b"English release notes",
|
||||||
}
|
}
|
||||||
for name, data in documents.items():
|
for name, data in documents.items():
|
||||||
self.write(name, data)
|
self.write(name, data)
|
||||||
self.write("docs/private.tmp", b"not documentation")
|
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():
|
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.assertIn("AGENTS.md", files)
|
||||||
self.assertNotIn("docs/private.tmp", files)
|
self.assertNotIn("docs/private.tmp", files)
|
||||||
|
|
||||||
def test_packages_without_removed_optional_root_documents(self):
|
def test_optional_root_documents_may_be_absent(self):
|
||||||
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
|
for name in ("AGENTS.md", "THIRD_PARTY_NOTICES.md"):
|
||||||
(self.root / name).unlink()
|
(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("AGENTS.md", files)
|
||||||
self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
|
self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
|
||||||
self.assertIn("GITEA-LICENSE", files)
|
self.assertIn("GITEA-LICENSE", files)
|
||||||
|
|||||||
@@ -2,44 +2,17 @@ name: Release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: Validate Templates
|
name: Validate
|
||||||
runs-on: linux-amd64-docker-small
|
uses: ./.github/workflows/validate.yml
|
||||||
steps:
|
permissions:
|
||||||
- uses: actions/checkout@v4
|
contents: read
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
package:
|
package:
|
||||||
name: Package & Release
|
name: Package
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
needs: validate
|
needs: validate
|
||||||
runs-on: linux-amd64-docker-small
|
runs-on: linux-amd64-docker-small
|
||||||
permissions:
|
permissions:
|
||||||
@@ -57,50 +30,44 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: Verify release tag matches the official snapshot
|
- name: Verify tag matches the snapshot
|
||||||
run: |
|
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
|
with open("gitea.lock.json", encoding="utf-8") as lock_file:
|
||||||
run: test -s ".github/release-notes/${GITHUB_REF_NAME}.md"
|
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
|
working-directory: tools
|
||||||
run: go run . preview all
|
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"
|
run: python -B .github/scripts/package_release.py --version "$TEMPLATE_RELEASE"
|
||||||
env:
|
env:
|
||||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Create Release
|
- name: Prepare release runtime
|
||||||
run: python -B .github/scripts/gitea_actions.py publish-release --version "$TEMPLATE_RELEASE"
|
uses: actions/setup-node@v4
|
||||||
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
|
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
node-version: '22'
|
||||||
- name: Create documentation reminder Issue
|
|
||||||
run: python -B .github/scripts/gitea_actions.py create-issue --version "$TEMPLATE_RELEASE"
|
- name: Release
|
||||||
env:
|
uses: actions/gitea-release-action@v1.3.7
|
||||||
TEMPLATE_RELEASE: ${{ github.ref_name }}
|
with:
|
||||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
server_url: ${{ github.server_url }}
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
repository: ${{ github.repository }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
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