From 27abd226c49aa44db2213f5bec923fd133255995 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 10 Oct 2026 23:01:30 +0800 Subject: [PATCH] Add the release workflow and its script Task: MIL-004#5 --- .gitea/workflows/release.yml | 41 ++++++ CHANGELOG.md | 4 + README.md | 2 +- guides/release-process.md | 24 +++- tests/test_release.py | 184 ++++++++++++++++++++++++ tools/release.py | 271 +++++++++++++++++++++++++++++++++++ wiki/Capabilities.da.md | 4 +- wiki/Capabilities.md | 2 +- wiki/Capabilities.ms.md | 4 +- 9 files changed, 523 insertions(+), 13 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 tests/test_release.py create mode 100644 tools/release.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..32e17bf --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +# Releases this repository (ADR-0006, ADR-0007, MIL-004 task 5). It is started +# by hand with the version to release; it creates the version tag, the release +# page from the changelog entry and moves the major tag, and only when the +# commit is the tip of main, its validation run is green and the changelog has +# an entry for the version. dry_run defaults to true: it checks and prints and +# writes nothing, so a release is a deliberate second run with dry_run false. +on: + workflow_dispatch: + inputs: + version: + description: "Version to release, MAJOR.MINOR.PATCH without a leading v" + required: true + dry_run: + description: "true checks only; false creates the tag, the release page and the major tag" + required: false + default: "true" + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Check and publish the release + env: + GITEA_API_URL: ${{ gitea.api_url }} + SOURCE_REPOSITORY: ${{ gitea.repository }} + RELEASE_CREDENTIALS: ${{ secrets.TOKEN_FOR_GITEA_RELEASE || github.token }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_DRY_RUN: ${{ inputs.dry_run }} + RELEASE_SHA: ${{ gitea.sha }} + run: python3 tools/release.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82591ad..a4b5536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ tagged the workflows are delivered from `main` and are not pinned. ### Added +- Workflow `release.yml`: started by hand with a version, it releases only the tip of + `main` whose validation run is green and whose changelog entry exists, creates + the version tag, the release page and the major tag, and does nothing but + check until `dry_run` is set to `false`. - Scoped workflow `sync-github-metadata.yml`: copies the description and topics of a Gitea repository to its GitHub push mirror on every push to `main`, by hand and daily at 03:17 UTC. A repository without a GitHub mirror is skipped diff --git a/README.md b/README.md index 3c61859..197f009 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Rows that say Implemented or Guides written exist today. The rest is planned and | Onboard a repository, manage runners, diagnose failures | Guides written ([onboarding](guides/onboarding.md), [troubleshooting](guides/troubleshooting.md)); scoped delivery to other repositories not yet verified; brief use cases open (MIL-002) | | Publish a repository wiki from `wiki/` (opt-in, delivered to every organization) | Implemented in `.gitea/scoped_workflows/publish-wiki.yml`, with offline tests; the pilot in a second repository is pending (MIL-006) | | Shared quality checks (Python and shell) | Implemented as the reusable workflow `.gitea/workflows/quality-checks.yml`, with offline tests and a trial in a second organization; adoption in RepoFoundry is in review ([guide](guides/quality-checks.md)) | -| Versioned release and publishing | Release process, changelog and upgrade guide defined; the first release `v1.0.0` and the release workflow are pending (MIL-004) ([guide](guides/upgrading.md)) | +| Versioned release and publishing | Release process, changelog and upgrade guide defined; the release workflow exists; the first release `v1.0.0` is pending (MIL-004) ([guide](guides/upgrading.md)) | ### 🔄 Sync GitHub metadata diff --git a/guides/release-process.md b/guides/release-process.md index be56663..9a9a47b 100644 --- a/guides/release-process.md +++ b/guides/release-process.md @@ -5,7 +5,8 @@ is validated, versioned and announced, and how consumers pin a version. The decisions are in ADR-0006; this guide is the checklist. It is not published on the wiki. -Until the release workflow exists (MIL-004 task 5) the steps are done by hand. +Steps 5 and 6 are done by the **Release** workflow (`.gitea/workflows/release.yml`, +`tools/release.py`); the manual commands below are the fallback. ## What a version is @@ -34,17 +35,26 @@ Until the release workflow exists (MIL-004 task 5) the steps are done by hand. new major version, and a new major version needs a Breaking entry. 4. **Get the approval** of S01 for the release, in the pull request that adds the changelog entry. -5. **Tag.** Create `vMAJOR.MINOR.PATCH` on the approved commit, then move - `vMAJOR` to it: +5. **Run the Release workflow in dry-run mode.** In the Actions tab of this + repository start **Release** on `main` with the version and `dry_run` + `true`. It checks that the commit is the tip of `main`, that its validation + run is green, that the changelog has an entry for the version and that the + tag does not exist, and writes nothing. +6. **Release.** Start **Release** again with `dry_run` `false`. It creates the + tag `vMAJOR.MINOR.PATCH`, the release page with the text of + `python tools/changelog.py notes x.y.z`, and moves `vMAJOR` to the commit + when this is the highest version of its major. The page on Gitea is the + only destination (ADR-0007); nothing is attached to it. The workflow uses + the token of its own run; if Gitea refuses it, store `TOKEN_FOR_GITEA_RELEASE` + as a secret with write access to this repository only. + + By hand, the same steps are: ```bash git tag -a v1.2.0 -m "v1.2.0" git push origin v1.2.0 git tag -f v1 && git push -f origin v1 ``` -6. **Announce.** Create the release page for the tag with the text of - `python tools/changelog.py notes x.y.z`, and say whether consumers must act. - The release page of this repository on Gitea is the only destination - (ADR-0007); nothing is attached to it. + and create the release page with the notes of the changelog entry. 7. **Verify.** One repository pinned to the previous exact tag still runs the same code; one repository on the major tag receives the release. diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..47b8d29 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,184 @@ +"""Offline tests for tools/release.py and .gitea/workflows/release.yml.""" + +import pytest +import yaml + +from workflow_source import REPO_ROOT, load_script + +release = load_script(REPO_ROOT / "tools" / "release.py", "release") +WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "release.yml" + +SHA = "a" * 40 +CONTEXT = "Validate workflows / validate (push)" + +CHANGELOG = """# Changelog + +## [Unreleased] + +### Added + +- Something later. + +## [1.1.0] - 2026-11-01 + +### Added + +- A workflow. + +## [1.0.0] - 2026-10-20 + +### Added + +- First release. +""" + + +class FakeGitea: + """Answers the calls of the release script and records the writes.""" + + def __init__(self, tip=SHA, status="success", tags=(), context=CONTEXT): + self.tip = tip + self.status = status + self.tags = list(tags) + self.context = context + self.writes = [] + + def __call__(self, url, method="GET", headers=None, body=None): + path = url.split("?")[0] + if method != "GET": + self.writes.append((method, path.rsplit("/repos/o/r", 1)[-1], body)) + return None + if path.endswith("/branches/main"): + return {"commit": {"id": self.tip}} + if path.endswith(f"/commits/{SHA}/status"): + if self.status is None: + return {"statuses": []} + return {"statuses": [{"context": self.context, "status": self.status}]} + if path.endswith("/tags"): + return [{"name": name} for name in self.tags] if "page=1" in url else [] + raise AssertionError(f"unexpected request {method} {url}") + + +@pytest.fixture +def env(tmp_path, monkeypatch): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(CHANGELOG, encoding="utf-8") + values = { + "GITEA_API_URL": "https://git.example/api/v1", + "SOURCE_REPOSITORY": "o/r", + "RELEASE_CREDENTIALS": "t" * 40, + "RELEASE_VERSION": "1.1.0", + "RELEASE_SHA": SHA, + "RELEASE_DRY_RUN": "false", + "CHANGELOG_FILE": str(changelog), + } + for key, value in values.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("RELEASE_BRANCH", raising=False) + monkeypatch.delenv("VALIDATION_CONTEXT", raising=False) + return monkeypatch + + +def run(gitea): + return release.main(request=gitea) + + +def test_green_commit_with_entry_creates_release_and_major_tag(env): + gitea = FakeGitea(tags=["v1.0.0", "v1"]) + assert run(gitea) == 0 + methods = [(m, p) for m, p, _ in gitea.writes] + assert methods == [("POST", "/releases"), ("DELETE", "/tags/v1"), ("POST", "/tags")] + release_body = gitea.writes[0][2] + assert release_body["tag_name"] == "v1.1.0" + assert release_body["target_commitish"] == SHA + assert "### Added" in release_body["body"] and "A workflow." in release_body["body"] + assert gitea.writes[2][2]["tag_name"] == "v1" and gitea.writes[2][2]["target"] == SHA + + +def test_first_release_creates_the_major_tag_without_deleting(env): + env.setenv("RELEASE_VERSION", "1.0.0") + gitea = FakeGitea(tags=[]) + assert run(gitea) == 0 + assert [(m, p) for m, p, _ in gitea.writes] == [("POST", "/releases"), ("POST", "/tags")] + + +def test_failed_validation_run_creates_no_tag(env): + gitea = FakeGitea(status="failure") + with pytest.raises(release.WorkflowError) as error: + run(gitea) + assert error.value.category == "validation" + assert gitea.writes == [] + + +def test_missing_validation_run_creates_no_tag(env): + gitea = FakeGitea(status=None) + with pytest.raises(release.WorkflowError) as error: + run(gitea) + assert "No validation run" in str(error.value) + assert gitea.writes == [] + + +def test_commit_that_is_not_the_tip_is_refused(env): + gitea = FakeGitea(tip="b" * 40) + with pytest.raises(release.WorkflowError) as error: + run(gitea) + assert "not the tip" in str(error.value) + assert gitea.writes == [] + + +def test_version_without_changelog_entry_is_refused(env): + env.setenv("RELEASE_VERSION", "1.2.0") + gitea = FakeGitea() + with pytest.raises(release.WorkflowError) as error: + run(gitea) + assert error.value.category == "validation" + assert gitea.writes == [] + + +def test_existing_tag_is_never_moved(env): + gitea = FakeGitea(tags=["v1.1.0", "v1.0.0", "v1"]) + with pytest.raises(release.WorkflowError) as error: + run(gitea) + assert "already exists" in str(error.value) + assert gitea.writes == [] + + +def test_older_patch_does_not_move_the_major_tag(env): + env.setenv("RELEASE_VERSION", "1.0.0") + gitea = FakeGitea(tags=["v1.1.0", "v1"]) + assert run(gitea) == 0 + assert [(m, p) for m, p, _ in gitea.writes] == [("POST", "/releases")] + + +def test_dry_run_is_the_default_and_writes_nothing(env, capsys): + env.delenv("RELEASE_DRY_RUN") + gitea = FakeGitea(tags=["v1.0.0", "v1"]) + assert run(gitea) == 0 + assert gitea.writes == [] + assert "Dry run" in capsys.readouterr().out + + +@pytest.mark.parametrize("version", ["v1.0.0", "1.0", "1.0.0-rc1", ""]) +def test_malformed_version_is_a_configuration_error(env, version): + env.setenv("RELEASE_VERSION", version) + with pytest.raises(release.WorkflowError) as error: + run(FakeGitea()) + assert error.value.category == "configuration" + + +def test_missing_token_is_a_configuration_error(env): + env.setenv("RELEASE_CREDENTIALS", " ") + with pytest.raises(release.WorkflowError) as error: + run(FakeGitea()) + assert error.value.category == "configuration" + + +def test_workflow_runs_the_file_and_defaults_to_a_dry_run(): + data = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + triggers = data.get("on", data.get(True)) + inputs = triggers["workflow_dispatch"]["inputs"] + assert inputs["dry_run"]["default"] == "true" + job = data["jobs"]["release"] + assert job["permissions"] == {"contents": "write"} + runs = [step.get("run", "") for step in job["steps"]] + assert "python3 tools/release.py" in runs diff --git a/tools/release.py b/tools/release.py new file mode 100644 index 0000000..5680b98 --- /dev/null +++ b/tools/release.py @@ -0,0 +1,271 @@ +"""Create a release of this repository: version tag, release page, major tag. + +Run by .gitea/workflows/release.yml (MIL-004 task 5, ADR-0006, ADR-0007). It +publishes a release only when all of these hold, and stops before any write +when one does not: + + - the version is MAJOR.MINOR.PATCH and CHANGELOG.md has an entry for it; + - the commit is the tip of the default branch; + - the validation run of that commit is green; + - the tag vMAJOR.MINOR.PATCH does not exist yet. + +It then creates the release page (which creates the tag) from the changelog +entry and moves the major tag vMAJOR to the commit when the version is the +highest of its major version. It reads its settings from the environment: + + GITEA_API_URL Gitea API base, for example https://host/api/v1 + SOURCE_REPOSITORY owner/repo that is released + RELEASE_CREDENTIALS the token of the run, as clear text + RELEASE_VERSION the version, for example 1.0.0 (no leading v) + RELEASE_SHA the commit to release + RELEASE_DRY_RUN "true" (default) checks and prints, writes nothing + RELEASE_BRANCH the default branch (default: main) + VALIDATION_CONTEXT the status context that must be green + (default: Validate workflows / validate (push)) + CHANGELOG_FILE default: CHANGELOG.md + +Every failure is printed as ``ERROR: [category] message`` with the categories +configuration, validation, credentials, permissions and api; no secret value +is printed. +""" + +import importlib.util +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +REQUEST_TIMEOUT_SECONDS = 30 +DEFAULT_CONTEXT = "Validate workflows / validate (push)" +_VERSION = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +_TAG = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +class WorkflowError(RuntimeError): + """A failure with a category. + + configuration: a setting or the changelog is missing or malformed. + validation: the release is not allowed (commit, run, tag or changelog). + credentials: Gitea rejected the token (HTTP 401). + permissions: the token is valid but not allowed (HTTP 403). + api: any other API or network failure. + """ + + def __init__(self, category, message): + super().__init__(f"[{category}] {message}") + self.category = category + self.detail = message + + +def load_changelog_module(): + """Import tools/changelog.py, which sits next to this file.""" + path = Path(__file__).resolve().parent / "changelog.py" + spec = importlib.util.spec_from_file_location("changelog", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def request_json(url, method="GET", headers=None, body=None): + request = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8") if body is not None else None, + headers=headers or {}, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: + content = response.read() + return json.loads(content) if content else None + except urllib.error.HTTPError as error: + target = url.split("?")[0] + detail = f"Gitea API request failed with HTTP {error.code} ({error.reason}) for {method} {target}" + if error.code == 401: + raise WorkflowError("credentials", f"{detail}. The token was rejected.") from None + if error.code == 403: + raise WorkflowError( + "permissions", + f"{detail}. The token lacks write access to the contents of the repository.", + ) from None + raise WorkflowError("api", detail) from None + except (urllib.error.URLError, TimeoutError) as error: + raise WorkflowError("api", f"Gitea API is unreachable ({error}).") from None + + +def parse_version(raw): + """Return the version as a tuple, or raise a configuration error.""" + text = (raw or "").strip() + match = _VERSION.match(text) + if not match: + raise WorkflowError( + "configuration", + f"'{text}' is not a version MAJOR.MINOR.PATCH (write 1.0.0, without a leading v).", + ) + return tuple(int(part) for part in match.groups()) + + +def split_repository(full_name): + owner, separator, repo = (full_name or "").partition("/") + if not separator or not owner or not repo: + raise WorkflowError("configuration", "Could not determine the Gitea repository.") + return owner, repo + + +def release_notes(changelog_text, version, changelog): + """Return the release page text for a version, or fail as a validation error.""" + problems = changelog.validate(changelog_text) + if problems: + raise WorkflowError("validation", "CHANGELOG.md breaks its convention: " + "; ".join(problems)) + try: + return changelog.notes(changelog_text, version) + except LookupError as error: + raise WorkflowError("validation", str(error)) from None + + +def list_tag_names(api, headers, request): + """Return every tag name of the repository.""" + names = [] + page = 1 + while True: + batch = request(f"{api}/tags?limit=50&page={page}", headers=headers) + if not batch: + return names + names += [item["name"] for item in batch] + if len(batch) < 50: + return names + page += 1 + + +def check_commit(api, headers, request, sha, branch, context): + """The commit is the tip of the branch and its validation run is green.""" + tip = request(f"{api}/branches/{urllib.parse.quote(branch, safe='')}", headers=headers) + tip_sha = (tip or {}).get("commit", {}).get("id", "") + if tip_sha != sha: + raise WorkflowError( + "validation", + f"{sha[:7]} is not the tip of {branch} ({tip_sha[:7] or 'unknown'}); " + "release from the latest commit of the default branch.", + ) + status = request(f"{api}/commits/{sha}/status", headers=headers) or {} + for item in status.get("statuses", []): + if item.get("context") == context: + if item.get("status") != "success": + raise WorkflowError( + "validation", + f"The validation run '{context}' of {sha[:7]} is {item.get('status')}, not success.", + ) + return + raise WorkflowError( + "validation", + f"No validation run '{context}' was found for {sha[:7]}; wait for it or run it first.", + ) + + +def plan_tags(tags, version): + """Return (exact tag, major tag, whether the major tag moves).""" + exact = "v{}.{}.{}".format(*version) + major = f"v{version[0]}" + if exact in tags: + raise WorkflowError("validation", f"The tag {exact} already exists; tags are never moved.") + same_major = [ + tuple(int(p) for p in m.groups()) + for m in (_TAG.match(name) for name in tags) + if m and int(m.group(1)) == version[0] + ] + moves = all(version > other for other in same_major) + return exact, major, moves + + +def publish(api, headers, request, sha, exact, major, moves, notes, major_exists): + """Create the release page (and with it the tag), then move the major tag.""" + request( + f"{api}/releases", + method="POST", + headers=headers, + body={ + "tag_name": exact, + "target_commitish": sha, + "name": exact, + "body": notes, + "draft": False, + "prerelease": False, + }, + ) + if not moves: + return + if major_exists: + request(f"{api}/tags/{major}", method="DELETE", headers=headers) + request( + f"{api}/tags", + method="POST", + headers=headers, + body={"tag_name": major, "target": sha, "message": f"{major} points to {exact}"}, + ) + + +def main(request=request_json): + version = parse_version(os.environ.get("RELEASE_VERSION")) + owner, repo = split_repository(os.environ.get("SOURCE_REPOSITORY")) + sha = (os.environ.get("RELEASE_SHA") or "").strip() + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise WorkflowError("configuration", "RELEASE_SHA must be the 40-character commit to release.") + token = (os.environ.get("RELEASE_CREDENTIALS") or "").strip() + if not token: + raise WorkflowError( + "configuration", + "No token for the release: the run has none and TOKEN_FOR_GITEA_RELEASE is not set.", + ) + dry_run = (os.environ.get("RELEASE_DRY_RUN") or "true").strip().lower() != "false" + branch = os.environ.get("RELEASE_BRANCH") or "main" + context = os.environ.get("VALIDATION_CONTEXT") or DEFAULT_CONTEXT + changelog_path = Path(os.environ.get("CHANGELOG_FILE") or "CHANGELOG.md") + + changelog = load_changelog_module() + name = "{}.{}.{}".format(*version) + try: + text = changelog_path.read_text(encoding="utf-8") + except OSError as error: + raise WorkflowError("configuration", f"Cannot read {changelog_path}: {error}") from None + notes = release_notes(text, name, changelog) + + api_url = os.environ["GITEA_API_URL"].rstrip("/") + api = f"{api_url}/repos/{urllib.parse.quote(owner, safe='')}/{urllib.parse.quote(repo, safe='')}" + headers = { + "Authorization": f"token {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + check_commit(api, headers, request, sha, branch, context) + tags = list_tag_names(api, headers, request) + exact, major, moves = plan_tags(tags, version) + print(f"Checks passed for {exact} on {sha[:7]}: changelog entry, tip of {branch}, green run, new tag.") + + if dry_run: + print( + f"Dry run: would create the release page {exact}" + + (f" and point {major} to it." if moves else f" and leave {major} where it is.") + ) + return 0 + + publish(api, headers, request, sha, exact, major, moves, notes, major in tags) + print(f"Released {exact} at {sha[:7]}.") + print( + f"The major tag {major} now points to {exact}." + if moves + else f"The major tag {major} was not moved: a higher {version[0]}.x release exists." + ) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except WorkflowError as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) diff --git a/wiki/Capabilities.da.md b/wiki/Capabilities.da.md index d939c99..f808a1f 100644 --- a/wiki/Capabilities.da.md +++ b/wiki/Capabilities.da.md @@ -1,4 +1,4 @@ - + > Dette er en oversættelse af [`Capabilities`](Capabilities). Den engelske side er autoritativ, og ændringer her bliver overskrevet, næste gang wikien publiceres. @@ -15,7 +15,7 @@ Rækker, der siger Implementeret eller Vejledninger er skrevet, findes i dag. Re | Tilslutning af et repository, administration af runners, fejlfinding | Vejledninger er skrevet ([tilslutning](Onboarding.da), [fejlfinding](Troubleshooting.da)); scoped levering til andre repositories er ikke verificeret endnu; korte use cases er åbne (MIL-002) | | Publicering af et repositorys wiki fra `wiki/` (tilmelding, leveres til alle organisationer) | Implementeret i `.gitea/scoped_workflows/publish-wiki.yml`, med offline-tests; pilotforsøget i et andet repository afventer (MIL-006) | | Fælles kvalitetskontrol (Python og shell) | Implementeret som det genbrugelige workflow `.gitea/workflows/quality-checks.yml`, med offline-tests og et forsøg i en anden organisation; tilslutningen i RepoFoundry er til gennemgang ([vejledning](QualityChecks.da)) | -| Versioneret udgivelse og publicering | Udgivelsesproces, changelog og opgraderingsvejledning er defineret; den første udgivelse `v1.0.0` og udgivelsesworkflowet mangler (MIL-004) ([vejledning](Upgrading.da)) | +| Versioneret udgivelse og publicering | Udgivelsesproces, changelog og opgraderingsvejledning er defineret; udgivelsesworkflowet findes; den første udgivelse `v1.0.0` mangler (MIL-004) ([vejledning](Upgrading.da)) | ### Synkronisering af GitHub-metadata diff --git a/wiki/Capabilities.md b/wiki/Capabilities.md index e7575a8..e711e4b 100644 --- a/wiki/Capabilities.md +++ b/wiki/Capabilities.md @@ -15,7 +15,7 @@ Rows that say Implemented or Guides written exist today. The rest is planned and | Onboard a repository, manage runners, diagnose failures | Guides written ([onboarding](Onboarding), [troubleshooting](Troubleshooting)); scoped delivery to other repositories not yet verified; brief use cases open (MIL-002) | | Publish a repository wiki from `wiki/` (opt-in, delivered to every organization) | Implemented in `.gitea/scoped_workflows/publish-wiki.yml`, with offline tests; the pilot in a second repository is pending (MIL-006) | | Shared quality checks (Python and shell) | Implemented as the reusable workflow `.gitea/workflows/quality-checks.yml`, with offline tests and a trial in a second organization; adoption in RepoFoundry is in review ([guide](QualityChecks)) | -| Versioned release and publishing | Release process, changelog and upgrade guide defined; the first release `v1.0.0` and the release workflow are pending (MIL-004) ([guide](Upgrading)) | +| Versioned release and publishing | Release process, changelog and upgrade guide defined; the release workflow exists; the first release `v1.0.0` is pending (MIL-004) ([guide](Upgrading)) | ### Sync GitHub metadata diff --git a/wiki/Capabilities.ms.md b/wiki/Capabilities.ms.md index 2807eed..83cfc0e 100644 --- a/wiki/Capabilities.ms.md +++ b/wiki/Capabilities.ms.md @@ -1,4 +1,4 @@ - + > Ini ialah terjemahan [`Capabilities`](Capabilities). Halaman bahasa Inggeris ialah teks yang sah, dan perubahan di sini akan ditimpa pada penerbitan wiki yang seterusnya. @@ -15,7 +15,7 @@ Baris yang menyatakan Dilaksanakan atau Panduan telah ditulis wujud hari ini. Se | Menyertakan repositori, mengurus runner, mendiagnosis kegagalan | Panduan telah ditulis ([menyertakan repositori](Onboarding.ms), [diagnosis](Troubleshooting.ms)); penghantaran scoped ke repositori lain belum disahkan; use case ringkas masih terbuka (MIL-002) | | Menerbitkan wiki repositori daripada `wiki/` (pilih menyertai, dihantar ke setiap organisasi) | Dilaksanakan dalam `.gitea/scoped_workflows/publish-wiki.yml`, dengan ujian luar talian; juruterbang dalam repositori kedua belum dibuat (MIL-006) | | Semakan kualiti bersama (Python dan shell) | Dilaksanakan sebagai aliran kerja boleh guna semula `.gitea/workflows/quality-checks.yml`, dengan ujian luar talian dan percubaan dalam organisasi kedua; penerimaan dalam RepoFoundry sedang disemak ([panduan](QualityChecks.ms)) | -| Keluaran berversi dan penerbitan | Proses keluaran, changelog dan panduan naik taraf telah ditakrifkan; keluaran pertama `v1.0.0` dan aliran kerja keluaran belum ada (MIL-004) ([panduan](Upgrading.ms)) | +| Keluaran berversi dan penerbitan | Proses keluaran, changelog dan panduan naik taraf telah ditakrifkan; aliran kerja keluaran sudah ada; keluaran pertama `v1.0.0` belum ada (MIL-004) ([panduan](Upgrading.ms)) | ### Menyegerakkan metadata GitHub -- 2.54.0