Merge pull request 'Add the release workflow (MIL-004 task 5)' (#80) from mil-004-release-workflow into main
This commit was merged in pull request #80.
This commit is contained in:
@@ -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
|
||||||
@@ -25,6 +25,10 @@ tagged the workflows are delivered from `main` and are not pinned.
|
|||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
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
|
hand and daily at 03:17 UTC. A repository without a GitHub mirror is skipped
|
||||||
|
|||||||
@@ -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) |
|
| 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) |
|
| 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)) |
|
| 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
|
### 🔄 Sync GitHub metadata
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
decisions are in ADR-0006; this guide is the checklist. It is not published
|
||||||
on the wiki.
|
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
|
## 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.
|
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
|
4. **Get the approval** of S01 for the release, in the pull request that adds
|
||||||
the changelog entry.
|
the changelog entry.
|
||||||
5. **Tag.** Create `vMAJOR.MINOR.PATCH` on the approved commit, then move
|
5. **Run the Release workflow in dry-run mode.** In the Actions tab of this
|
||||||
`vMAJOR` to it:
|
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
|
```bash
|
||||||
git tag -a v1.2.0 -m "v1.2.0" <commit>
|
git tag -a v1.2.0 -m "v1.2.0" <commit>
|
||||||
git push origin v1.2.0
|
git push origin v1.2.0
|
||||||
git tag -f v1 <commit> && git push -f origin v1
|
git tag -f v1 <commit> && git push -f origin v1
|
||||||
```
|
```
|
||||||
6. **Announce.** Create the release page for the tag with the text of
|
and create the release page with the notes of the changelog entry.
|
||||||
`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.
|
|
||||||
7. **Verify.** One repository pinned to the previous exact tag still runs the
|
7. **Verify.** One repository pinned to the previous exact tag still runs the
|
||||||
same code; one repository on the major tag receives the release.
|
same code; one repository on the major tag receives the release.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- translation of Capabilities.md, source sha256 c1d5b1f173ef -->
|
<!-- translation of Capabilities.md, source sha256 94af1c3e6949 -->
|
||||||
|
|
||||||
> Dette er en oversættelse af [`Capabilities`](Capabilities). Den engelske side er autoritativ, og ændringer her bliver overskrevet, næste gang wikien publiceres.
|
> 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) |
|
| 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) |
|
| 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)) |
|
| 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
|
### Synkronisering af GitHub-metadata
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| 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) |
|
| 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)) |
|
| 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
|
### Sync GitHub metadata
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- translation of Capabilities.md, source sha256 c1d5b1f173ef -->
|
<!-- translation of Capabilities.md, source sha256 94af1c3e6949 -->
|
||||||
|
|
||||||
> Ini ialah terjemahan [`Capabilities`](Capabilities). Halaman bahasa Inggeris ialah teks yang sah, dan perubahan di sini akan ditimpa pada penerbitan wiki yang seterusnya.
|
> 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) |
|
| 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) |
|
| 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)) |
|
| 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
|
### Menyegerakkan metadata GitHub
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user