diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index 9a5c8d1..950e1df 100644 --- a/.github/ISSUE_TEMPLATE/release.yml +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -1,5 +1,5 @@ name: Release tracking -description: Agree on module versions, release scope, and compatibility before publication. +description: Track one version for all maintained public components. title: "[Release]: " body: - type: markdown @@ -7,14 +7,14 @@ body: value: | Follow docs/RELEASING.md. Keep this issue open through the preparation PR using Refs, then close it only after publication and consumer checks. - This community project publishes modules independently; merging source - does not publish a version. - - type: textarea - id: modules + All maintained public modules release together at one version. Merging + an ordinary source PR does not publish a version. + - type: input + id: version attributes: - label: Modules and target versions - description: List each directory and version, including unpublished internal dependencies. Development and deprecated frozen modules are excluded. - placeholder: "logger v0.1.0; . v0.1.0" + label: Unified release version + description: State the shared target or auto/patch/minor/major increment. Every maintained public module is included; development and frozen modules are excluded. + placeholder: "patch (all maintained components)" validations: required: true - type: textarea @@ -28,7 +28,7 @@ body: id: history attributes: label: Previous versions and accumulated changes - description: Each module uses its own previous published tag. Link relevant PRs and identify missing historical summaries or commits without PRs. + description: Link the previous project version and relevant PRs. The root Release groups actual changes by component and shows the full version table. validations: required: true - type: textarea @@ -43,7 +43,7 @@ body: attributes: label: Before publication options: - - label: I understand that publication needs an explicit Actions dispatch after the preparation PR is merged. + - label: I understand that merging a preparation PR with automatic publication enabled authorizes the full unified release after checks pass; manual publication remains available. required: true - label: I removed credentials and private data from release scope and evidence. required: true diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 17e4636..ca3d7ee 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -3,11 +3,6 @@ name: Prepare release on: workflow_dispatch: inputs: - scope: - description: Module directory, comma-separated directories, or all - required: true - default: all - type: string bump: description: Version increment (auto uses accumulated release notes) required: true @@ -54,22 +49,13 @@ jobs: - name: Prepare metadata, tracking issue, and PR env: GH_TOKEN: ${{ github.token }} - RELEASE_SCOPE: ${{ inputs.scope }} RELEASE_BUMP: ${{ inputs.bump }} RELEASE_AUTOMATIC: ${{ inputs.auto_publish }} run: | python3 - <<'PY' import os import subprocess - scope = os.environ['RELEASE_SCOPE'].strip() - command = ['python3', 'tools/release.py', 'prepare', '--bump', os.environ['RELEASE_BUMP']] - if scope == 'all': - command.append('--all') - else: - for directory in scope.split(','): - if not directory.strip(): - raise SystemExit('Select module directories or all.') - command.extend(['--module', directory.strip()]) + command = ['python3', 'tools/release.py', 'prepare', '--all', '--bump', os.environ['RELEASE_BUMP']] if os.environ['RELEASE_AUTOMATIC'] == 'true': command.append('--auto-publish') subprocess.run(command, check=True) diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 67d597d..66563ca 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -274,3 +274,4 @@ Race-detector execution is intentionally excluded because CGO must remain disabl - [x] REL-DRAFT: Resolve matching Release drafts through authenticated listing because GitHub's by-tag endpoint returns published Releases. Reject duplicate matches and retain reviewed-note/tag conflict checks before publication. Thirty release regressions passed, including the real GoReleaser OSS v2.18.2 draft/finalization fixture with draft-by-tag 404 responses (2026-10-04). The first hosted attempt created the reviewed root tag/draft at PR #16's SHA and then stopped at the old draft lookup; preserve those objects for recovery. Restrict progress artifacts to reports, notes, configurations, and logs. Public consumer acceptance remains separate below; see issue #19. - [x] REL-VISIBILITY: Bound post-GoReleaser read-after-write waiting to 30 seconds, validate every observed Release against frozen notes, and propagate conflicts/API errors immediately without repeating writes. Thirty-three offline release regressions passed, including delayed draft/finalization, timeout preservation, and immediate error propagation (2026-10-04). Hosted run 37206952188 published and consumer-verified root v0.1.0, then stopped before the new commons/regex draft became visible; issue #20 tracks recovery. - [x] REL-PUBLIC: Publish all 27 maintained public modules as v0.1.0 from merged preparation PR #16 at immutable SHA 34fcc751355a2b29bca393748139ff96dd8bbf2a. The hosted GoReleaser OSS v2.18.2 run finalized every Release only after independent public download, tidy, build, module/checksum verification, and rejection of local replacements, with CGO disabled, GOWORK=off and fresh session caches. Release issue #15 closed automatically after completion. The frozen tracer/xray adapter was excluded. See [RELEASE_ACCEPTANCE.json](RELEASE_ACCEPTANCE.json) (2026-10-04). Hosted preparation controls and native PR/main checks were exercised; browser form rendering and default-token authorization remain separate rollout checks. +- [x] REL-UNIFIED: Replace new component-scoped releases with one version for all maintained public modules, preserving independent Go paths and prefixed tags. Add project release_version, aggregate auto-increment policy, cohort-wide reserved-version avoidance, synchronized internal requirements, schema-2 full-cohort validation, component/repository grouped root notes, and root finalization last with the stable Latest label. Twenty-one isolated automation and 37 release regressions passed, including Logger-only cohort bumps, divergent published baselines/reserved nested tags, partial/mixed-plan rejection, aligned requirements, failure/resume ordering, legacy schema-1 recovery, and real GoReleaser OSS v2.18.2 root/component/prerelease draft/finalization and Latest configuration. Eighteen contribution/form regressions and actionlint v1.7.12 passed (2026-10-05). This records implementation acceptance; no new public version was prepared or published. See issue #23 and [Releasing modules](RELEASING.md). diff --git a/docs/MAINTAINING.md b/docs/MAINTAINING.md index 7247bb2..2b1cdcd 100644 --- a/docs/MAINTAINING.md +++ b/docs/MAINTAINING.md @@ -83,7 +83,7 @@ and [required-check troubleshooting](https://docs.github.com/en/pull-requests/ho ## Rollout verification -For independent version publication, follow [Releasing modules](RELEASING.md). +For unified version publication across independent modules, follow [Releasing modules](RELEASING.md). Preparation automatically generates module versions, dependency metadata, accumulated notes, a tracking issue, and a PR. The publisher reuses required checks, binds the reviewed plan to the merged preparation SHA, and keeps the diff --git a/docs/MODULES.md b/docs/MODULES.md index 649dba7..14f992e 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -4,6 +4,12 @@ The implementation uses twenty-eight public modules and three development module ## Module boundaries +Future releases use [one shared version](RELEASING.md) for all maintained public +modules. `tools/modules.json` records the project `release_version`, and each +maintained module version matches it. Fixing only Logger still releases the full +cohort; the root version Release groups actual component changes and lists every +module. Development modules and the frozen X-Ray adapter remain excluded. + All module paths start with `github.com/rambow-cloud/powertools-lambda-go`. | Directory | Responsibility | Initial tag | diff --git a/docs/PUBLISHING_PLAN.md b/docs/PUBLISHING_PLAN.md index 9aba7f6..eab0936 100644 --- a/docs/PUBLISHING_PLAN.md +++ b/docs/PUBLISHING_PLAN.md @@ -1,6 +1,6 @@ # Optional utilities and Go publishing -Assessment date: 2026-09-14; publication identity updated 2026-09-27. Feature modules have independent dependencies and versions in one repository. Module boundaries are implemented; see [MODULES.md](MODULES.md) for the actual layout and maintenance commands. The selected repository path is `github.com/rambow-cloud/powertools-lambda-go`. The 27 maintained public modules were published as `v0.1.0` on 2026-10-04; see [public release acceptance](RELEASE_ACCEPTANCE.json). The frozen X-Ray adapter was excluded. +Assessment date: 2026-09-14; publication identity updated 2026-09-27; unified version policy adopted 2026-10-05. Feature modules have independent dependencies and one shared release version in this repository. Module boundaries are implemented; see [MODULES.md](MODULES.md) for the actual layout and maintenance commands. The selected repository path is `github.com/rambow-cloud/powertools-lambda-go`. The 27 maintained public modules were published as `v0.1.0` on 2026-10-04; see [public release acceptance](RELEASE_ACCEPTANCE.json). The frozen X-Ray adapter was excluded. ## Consumer experience @@ -27,9 +27,9 @@ The example uses the publicly verified Parser `v0.1.0` release. The existing uti | Compile utility packages absent from the application's transitive imports | Not required | Not required | | Source download boundary | Entire containing module | Selected modules and required dependencies | | Dependency requirements and minimum Go version | Shared `go.mod` | Each module owns its requirements | -| Select independent utility versions | No | Yes | +| Release version policy | One version | One shared version across independent modules | | Version tag for Parser v0.1.0 | `v0.1.0` | `parser/v0.1.0` | -| Maintenance | One release/test boundary | Per-module releases and consumer compatibility checks | +| Maintenance | One release/test boundary | One release cohort with per-module consumer compatibility checks | A single module does not automatically compile or link every listed dependency into a Lambda binary. Conversely, importing one package does not make the module's dependency metadata, source archive, or version independent. Graph pruning and lazy loading can avoid some unrelated dependency retrieval; they are not an extras mechanism or a guarantee that unrelated dependency metadata is never consulted. @@ -46,7 +46,7 @@ The split follows these boundaries: 3. Keep exactly one shared invocation context key and cold-start state. Feature modules under the existing repository import prefix can still access the root `internal/invocation` package: Go's `internal` rule is based on the parent import path, not simply on module boundaries. Moving it to `commons/internal` would prevent sibling utility imports; copying it would break wrapper composition. The root core must not import feature modules, including through tests. 4. Isolate `tracer/xray` from the OTel Tracer module to keep the deferred legacy SDK optional at the module level. Initially keep the Parameters providers together; consider finer provider modules only if consumer dependency budgets justify them. 5. Move examples and integration programs into development modules so their combined imports do not pull every utility back into the core module. Use `go.work` for local composition. Release verification must also work with `GOWORK=off` and published dependency versions, without local `replace` paths. -6. Keep a consistent initial release version if helpful, but issue a tag for each changed module (`parser/v0.1.0`, `signer/v0.1.0`, and so on). Publish shared dependencies first. A future major v2 requires the corresponding `/v2` module/import suffix. Do not promise a transparent split after consumers already depend on root-module versions; perform it before initial publication. +6. Release every maintained public module at one shared version, with its required tag (`parser/v0.1.1`, `signer/v0.1.1`, and so on). Publish shared dependencies first and finalize the root unified summary last. A future major v2 requires the corresponding `/v2` module/import suffix. The module split predates public publication; the unified version policy preserves those module paths and boundaries. Optional parser/schema and Kafka codec integrations should use explicit adapters with small interfaces, not feature build tags or a root package that imports all utilities. Build tags do not offer equivalent dependency isolation because module maintenance considers files across build tags. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 95a98d6..1e0b7a7 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,7 +1,9 @@ -# Releasing independent Go modules +# Releasing Go modules at one version Several PRs can merge before a version is published. Source merges do not -publish modules. Each module has its own version and release-note boundary. +publish modules. All maintained public modules release together at one version, +even when only one component changes. Go module boundaries and import paths stay +independent; nested modules still require their own prefixed tags. The repository publishes Go source modules and GitHub Releases with GoReleaser OSS; Lambda ZIPs remain CI example artifacts. No cloud AWS account is needed. @@ -20,7 +22,7 @@ description per line, with the module directory and change type: Use module directories from `tools/modules.json`, including `.` for root Commons. Use `repository` for repository-only tooling and contribution work; -these entries do not appear in individual module notes. Types are `breaking`, +these entries appear in the root version's unified summary. Types are `breaking`, `feature`, `fix`, `documentation`, and `maintenance`. A PR may describe several changes across several modules. For a change with no release impact, write `None: `. The contribution policy requires the section and @@ -29,8 +31,10 @@ whether the declared modules and summaries accurately describe the changes. For Logger v0.1.1, the generator collects all merged PRs between `logger/v0.1.0` and the selected source commit, then includes only Logger entries. -A Metrics release uses its own previous tag; it does not lose changes just -because Logger was released first. One PR fixing three Logger behaviors can +A Metrics component note uses its previous tag. The root `v0.1.1` Release groups +all actual changes by component and includes repository notes and a full module +version table. Modules with only dependency alignment are identified separately. +One PR fixing three Logger behaviors can produce three notes with the same PR link. Notes group breaking changes, features, fixes, documentation, and maintenance in that order. @@ -40,7 +44,7 @@ previous stable releases; prereleases may compare against previous prereleases. Only published GitHub Releases on the source ancestry are release boundaries, not draft Releases or unrelated module tags. -## Prepare one component or all components +## Prepare the unified release After the workflows are merged, open [Actions: Prepare release](https://github.com/rambow-cloud/powertools-lambda-go/actions/workflows/prepare-release.yml) @@ -48,19 +52,18 @@ and choose **Run workflow** on `main`: | Input | Value | |---|---| -| `scope` | A module directory such as `logger`, several comma-separated directories such as `logger,metrics`, or `all` | | `bump` | Leave `auto` for note-based versioning, or select `patch`, `minor`, or `major` | | `auto_publish` | Leave checked to publish after the preparation PR merges and main checks pass | The workflow creates a Release tracking issue and a preparation PR. It computes -versions, includes unpublished internal dependencies transitively, synchronizes +one shared version, includes every maintained public module, synchronizes manifest versions and internal `go.mod` requirements, rebuilds `go.work` version mappings, tidies dependency sums, and freezes each module's accumulated notes. No manual version-file edits, module-list script, issue number, SHA, or plan name -are required. `all` selects all maintained public modules, including Commons; +are required. Every release includes all maintained public modules and Commons; development modules and the frozen `tracer/xray` adapter are excluded. -Review the generated PR's module/version table, included dependencies, notes, +Review the generated PR's shared version, full module table, notes, initial compatibility statements, and automatic-publication setting. Required checks use the native PR workflows. Preparation waits for those runs to appear and attempts to authorize pending runs with its Actions write permission. If @@ -101,30 +104,30 @@ authentication while the organization policy stays restricted. | Breaking change while on v1 | Stop for a separately reviewed v2 module-path migration | For example, Logger fixes produce `v0.1.0 → v0.1.1`; a Logger feature produces -`v0.1.0 → v0.2.0`. Each selected module uses its own previous release and can -have a different version. Reserved tags, including drafts or tags without -Releases, are never reused; preparation advances beyond reserved versions. +`v0.1.0 → v0.2.0` for every maintained component. The strongest accumulated change +across components and repository notes determines one automatic increment. +Reserved tags from any maintained module, including drafts or tags without +Releases, are never reused; the entire cohort advances beyond reserved versions. An explicit `major` increment can move v0 to v1; v2+ paths are not automated. -Versions live in the manifest and Git tags. The automation updates internal +The project version lives in `tools/modules.json` as `release_version`; all +maintained module entries must match it. The automation updates internal dependency requirements and workspace mappings while preserving module paths and Go language-version directives. Workspace consumer `go.mod` files are -synchronized too; publication scope is the requested components plus required -unpublished dependencies. Dependency changes appear in the plan and selected -modules' notes, and other consumers receive preparation-PR notes for their next -release. Go commands keep `CGO_ENABLED=0`; no module-file replacements are added. +synchronized too. Publication always covers the full maintained cohort. +Dependency changes appear in the frozen plan and component notes. +Go commands keep `CGO_ENABLED=0`; no module-file replacements are added. Tidy uses the existing local module fixtures before published external modules. Maintainers who prefer the CLI can start from a clean checkout of current -`origin/main`, authenticate `gh`, and run either command: +`origin/main`, authenticate `gh`, and run: ```sh -uv run --no-project python tools/release.py prepare --module logger --auto-publish uv run --no-project python tools/release.py prepare --all --auto-publish ``` -These commands create the issue, branch, and PR automatically. Repeat `--module` -to select several components and use `--bump patch` to override the version +This command creates the issue, branch, and PR automatically. Component selection +is no longer supported. Use `--bump patch` to override the shared version policy. CLI preparation enables automatic publication only with `--auto-publish`. For local metadata/plan generation with no GitHub writes, pass `--local --issue 123`; `--issue` otherwise reuses an existing open tracking issue. `--plan` and @@ -145,7 +148,7 @@ The first release therefore needs no hand-authored overrides file. Maintainers can improve generated historical summaries and initial capability statements in the preparation PR, or supply optional JSON overrides for preparation. Each `pull_requests` value contains the release-note section without its heading. -Repository-only entries stay out of module notes. +Repository-only entries are included in the root unified summary. ```json { @@ -205,7 +208,9 @@ and an active run is not canceled by a newer request. For each module, publication creates a tag at the selected SHA and uses GoReleaser to create its draft GitHub Release with the reviewed notes. After a real public consumer passes, a second GoReleaser invocation publishes that same -draft before proceeding to the next module. Root Commons uses `vX.Y.Z`; Logger uses +draft before proceeding to the next module. The root summary stays draft until +every component consumer and Release succeeds, then is finalized last. It marks +the stable project version as GitHub's Latest Release. Root Commons uses `vX.Y.Z`; Logger uses `logger/vX.Y.Z`. Consumers use fresh caches, `GOWORK=off`, `CGO_ENABLED=0`, the public Go proxy and checksum database, no local proxies/replacements, and a consumer build. This differs from synthetic local module verification. @@ -213,13 +218,20 @@ After all selected modules pass, the workflow posts Release links and closes the tracking issue. The built-in GitHub token performs writes. Publication does not depend on a -tag-triggered follow-up workflow. Independently versioned modules do not set -a repository-wide latest Release label. Prerelease versions create prerelease +tag-triggered follow-up workflow. Component Releases do not replace the root +Latest label. Prerelease versions create prerelease Releases. v2+ module-path migrations need a separate feature and are rejected by this initial tool. ## GoReleaser configuration and independent tags +New preparations use schema 2: the complete maintained module set, one version, +aligned internal requirements, and frozen unified summary are required before +publication writes. Immutable schema-1 plans from before this policy can still +be recovered at their original commits, whose manifests lack `release_version`. +They cannot be used to bypass the policy at a new preparation commit. Published +`v0.1.0` tags, notes, and acceptance records are not rewritten by this change. + The workflow installs GoReleaser OSS **v2.18.2** through a commit-pinned official action. `.goreleaser.json` is the shared configuration; GoReleaser accepts JSON through its YAML parser. The publisher derives per-module configurations under diff --git a/tools/modules.json b/tools/modules.json index 6a35627..45aa968 100644 --- a/tools/modules.json +++ b/tools/modules.json @@ -1,5 +1,6 @@ { "base": "github.com/rambow-cloud/powertools-lambda-go", + "release_version": "v0.1.0", "modules": [ { "directory": ".", diff --git a/tools/release.py b/tools/release.py index c9c02cf..c976999 100644 --- a/tools/release.py +++ b/tools/release.py @@ -13,7 +13,7 @@ from urllib.parse import quote from urllib.request import Request, urlopen -from release_notes import CATEGORIES, render_notes, validate_direct_note +from release_notes import CATEGORIES, render_notes, render_unified_notes, validate_direct_note ROOT = Path(__file__).resolve().parents[1] @@ -77,7 +77,16 @@ def manifest(): data = json.loads((ROOT / "tools/modules.json").read_text(encoding="utf-8")) if data["base"] != "github.com/" + REPOSITORY: raise ValueError("Unexpected publication repository.") - return {module["directory"]: module for module in data["modules"]} + modules = {module["directory"]: module for module in data["modules"]} + if "release_version" in data: + version_key(data["release_version"]) + if any(module["version"] != data["release_version"] for module in modules.values() if module["public"] and module.get("status") != "deprecated-frozen"): + raise ValueError("Maintained module versions must match the project release_version.") + return modules + + +def manifest_version(): + return json.loads((ROOT / "tools/modules.json").read_text(encoding="utf-8")).get("release_version") def tag_name(directory, version): @@ -157,9 +166,21 @@ def write_json(path, data): temporary.replace(path) -def validate_plan(plan, modules): - if plan.get("schema_version") != 1 or not isinstance(plan.get("issue"), int) or plan["issue"] < 1 or not SHA.fullmatch(plan.get("source_sha", "")): +def validate_plan(plan, modules, project_version=None): + if plan.get("schema_version") not in {1, 2} or not isinstance(plan.get("issue"), int) or plan["issue"] < 1 or not SHA.fullmatch(plan.get("source_sha", "")): raise ValueError("Invalid release plan identity.") + unified = plan["schema_version"] == 2 + if project_version is not None and (not unified or plan.get("release_version") != project_version): + raise ValueError("Current release policy requires a unified plan matching release_version.") + if unified: + version_key(plan.get("release_version", "")) + cohort = {directory for directory, module in modules.items() if module["public"] and module.get("status") != "deprecated-frozen"} + if "." not in cohort or {item["directory"] for item in plan.get("modules", [])} != cohort or any(item["version"] != plan["release_version"] for item in plan.get("modules", [])): + raise ValueError("Unified plans must include every maintained public module at one version.") + reviewed = next(item["reviewed_prs"] for item in plan["modules"] if item["directory"] == ".") + for entry in plan.get("repository_entries", []): + if entry["module"] != "repository" or entry["type"] not in CATEGORIES or entry["pr"] not in reviewed or not entry["description"].strip(): + raise ValueError("Invalid reviewed repository note.") if not isinstance(plan.get("auto_publish", False), bool): raise ValueError("Automatic publication authorization must be a boolean.") selected = {} @@ -197,6 +218,8 @@ def validate_plan(plan, modules): version_key(update["from"]) version_key(update["to"]) expected = render_notes(REPOSITORY, directory, item["version"], previous, item["entries"], item["initial_summary"], item["untracked_commits"], updates) + if unified and directory == ".": + expected = render_unified_notes(REPOSITORY, plan) if item["notes"] != expected: raise ValueError(f"Frozen notes do not match reviewed entries: {directory}") selected[directory] = item @@ -236,6 +259,14 @@ def dependency_order(selected, modules, requirements, available): return result +def validate_unified_requirements(version, requirements): + for values in requirements.values(): + for requirement in values: + path = requirement["Path"] + if (path == "github.com/" + REPOSITORY or path.startswith("github.com/" + REPOSITORY + "/")) and requirement["Version"] != version: + raise ValueError("Unified internal requirements must use the cohort version.") + + def latest_checks(api, sha): runs = [] page = 1 @@ -324,7 +355,7 @@ def preflight(args, api): raise ValueError("Release checkout must be clean; commit reviewed changes before preflight.") plan = json.loads(plan_path(args.plan).read_text(encoding="utf-8")) modules = manifest() - selected = validate_plan(plan, modules) + selected = validate_plan(plan, modules, manifest_version()) if plan["issue"] != args.issue: raise ValueError("Dispatch issue does not match the reviewed plan.") authorize_actor(api) @@ -360,6 +391,8 @@ def preflight(args, api): for update in selected[directory].get("dependency_updates", []): if not any(requirement["Path"] == update["path"] and requirement["Version"] == update["to"] for requirement in requirements[directory]): raise ValueError("Reviewed dependency updates disagree with go.mod.") + if plan["schema_version"] == 2: + validate_unified_requirements(plan["release_version"], requirements) def available(tag): release = release_for_tag(api, tag) return bool(release and not release["draft"] and remote_tag(api, tag)) @@ -435,6 +468,7 @@ def run_goreleaser(directory, item, args, output, config, token=None, draft=True phase_config = config.parent / ("goreleaser-" + stage + ".json") configuration = json.loads(config.read_text(encoding="utf-8")) configuration["release"]["draft"] = draft + configuration["release"]["make_latest"] = bool(getattr(args, "unified", False) and directory == "." and not draft and "-" not in item["version"]) write_json(phase_config, configuration) command = ["goreleaser", "release", "--clean", "--config", str(phase_config), "--release-notes", str(output / (name + ".md")), @@ -452,6 +486,7 @@ def run_goreleaser(directory, item, args, output, config, token=None, draft=True def publish(args, api): plan, selected, modules, order = preflight(args, api) + args.unified = plan["schema_version"] == 2 output = ROOT / "dist/releases" / args.plan output.mkdir(parents=True, exist_ok=True) digest = hashlib.sha256(plan_path(args.plan).read_bytes()).hexdigest() @@ -475,6 +510,7 @@ def publish(args, api): print(report["publication"]) return session = Path(tempfile.mkdtemp(prefix="consumer-", dir=output)) + deferred_root = None try: for directory in order: item = selected[directory] @@ -501,9 +537,17 @@ def publish(args, api): record.update(consumer_verified=True, evidence=evidence) write_json(output / "progress.json", report) if release["draft"]: - run_goreleaser(directory, item, args, output, configs[directory], token=api.token, draft=False) - release = await_release_phase(api, directory, item, args.sha, draft=False) - record.update(release_published=True, url=release["html_url"]) + if plan["schema_version"] == 2 and directory == ".": + deferred_root = record + else: + run_goreleaser(directory, item, args, output, configs[directory], token=api.token, draft=False) + release = await_release_phase(api, directory, item, args.sha, draft=False) + record.update(release_published=not release["draft"], url=release["html_url"]) + write_json(output / "progress.json", report) + if deferred_root: + run_goreleaser(".", selected["."], args, output, configs["."], token=api.token, draft=False) + release = await_release_phase(api, ".", selected["."], args.sha, draft=False) + deferred_root.update(release_published=True, url=release["html_url"]) write_json(output / "progress.json", report) report["completed"] = True write_json(output / "progress.json", report) @@ -523,9 +567,7 @@ def main(): commands = parser.add_subparsers(dest="command", required=True) prepare_parser = commands.add_parser("prepare", help="Write a reviewed plan without publishing") prepare_parser.add_argument("--issue", type=int, help="Reuse an open release tracking issue; otherwise create one") - scope = prepare_parser.add_mutually_exclusive_group(required=True) - scope.add_argument("--module", action="append", help="Module directory; repeat to select several") - scope.add_argument("--all", action="store_true", help="All maintained public modules") + prepare_parser.add_argument("--all", action="store_true", help="All maintained public modules (always selected)") prepare_parser.add_argument("--plan", help="Optional plan name; generated by default") prepare_parser.add_argument("--bump", choices=("auto", "patch", "minor", "major"), default="auto") prepare_parser.add_argument("--auto-publish", action="store_true", help="Authorize publication after this preparation PR merges and main CI passes") diff --git a/tools/release_automation.py b/tools/release_automation.py index 70460b2..6e3f138 100644 --- a/tools/release_automation.py +++ b/tools/release_automation.py @@ -24,7 +24,7 @@ def resolve_preparation(number, api): name = plans[0].removeprefix("releases/").removesuffix(".json") release.plan_path(name) plan = json.loads(release.git("show", f"{sha}:{plans[0]}")) - if plan.get("schema_version") != 1 or not isinstance(plan.get("issue"), int) or plan["issue"] < 1: + if plan.get("schema_version") not in {1, 2} or not isinstance(plan.get("issue"), int) or plan["issue"] < 1: raise ValueError("Invalid preparation plan identity.") if not isinstance(plan.get("auto_publish", False), bool): raise ValueError("Invalid automatic publication authorization.") diff --git a/tools/release_notes.py b/tools/release_notes.py index 87c0562..5339ace 100644 --- a/tools/release_notes.py +++ b/tools/release_notes.py @@ -86,3 +86,51 @@ def render_notes(repository, module, version, previous_tag, entries, initial_sum lines += [f"**Source:** https://github.com/{repository}/tree/{tag}", ""] lines += ["## Installation", "", "```sh", f"go get github.com/{repository}{'' if module == '.' else '/' + module}@{version}", "```", ""] return "\n".join(lines) + + +def render_unified_notes(repository, plan): + """Render the root version's complete, component-grouped release summary.""" + version = plan["release_version"] + lines = [f"# {version}", "", "All maintained public modules use this version.", "", + f"**Publication status:** https://github.com/{repository}/issues/{plan['issue']}", ""] + for item in plan["modules"]: + directory = item["directory"] + direct = {sha: note for sha, note in item["untracked_commits"].items() if directory in note["modules"]} + if not (item["entries"] or item["initial_summary"] or direct): + continue + lines += ["## " + ("Commons" if directory == "." else directory), ""] + if item["initial_summary"]: + lines += [item["initial_summary"].strip(), ""] + for kind, title in CATEGORIES.items(): + entries = [entry for entry in item["entries"] if entry["type"] == kind] + if entries: + lines += [f"### {title}", ""] + lines += [f"- {entry['description']} ([#{entry['pr']}](https://github.com/{repository}/pull/{entry['pr']}))" for entry in entries] + lines.append("") + if direct: + lines += ["### Changes without a pull request", ""] + lines += [f"- {note['description']} ([{sha[:7]}](https://github.com/{repository}/commit/{sha}))" for sha, note in sorted(direct.items())] + lines.append("") + root = next(item for item in plan["modules"] if item["directory"] == ".") + repository_direct = {sha: note for sha, note in root["untracked_commits"].items() if "repository" in note["modules"]} + if plan.get("repository_entries") or repository_direct: + lines += ["## Repository", ""] + for kind, title in CATEGORIES.items(): + entries = [entry for entry in plan.get("repository_entries", []) if entry["type"] == kind] + if entries: + lines += [f"### {title}", ""] + lines += [f"- {entry['description']} ([#{entry['pr']}](https://github.com/{repository}/pull/{entry['pr']}))" for entry in entries] + lines.append("") + lines += [f"- {note['description']} ([{sha[:7]}](https://github.com/{repository}/commit/{sha}))" for sha, note in sorted(repository_direct.items())] + lines.append("") + lines += ["## Module versions", "", "| Module | Version | Changes |", "|---|---|---|"] + for item in plan["modules"]: + directory = item["directory"] + tag = version if directory == "." else directory + "/" + version + changed = item["entries"] or item["initial_summary"] or any(directory in note["modules"] for note in item["untracked_commits"].values()) + status = "Component changes" if changed else "Dependency alignment" if item.get("dependency_updates") else "No component changes" + lines.append(f"| `{directory}` | [{version}](https://github.com/{repository}/releases/tag/{tag}) | {status} |") + lines += ["", "Internal dependency requirements are synchronized to this version. See each component Release for dependency details.", ""] + if root["previous_tag"]: + lines += [f"**Full changelog:** https://github.com/{repository}/compare/{root['previous_tag']}...{version}", ""] + return "\n".join(lines) diff --git a/tools/release_prepare.py b/tools/release_prepare.py index a7e0310..b9b4e8d 100644 --- a/tools/release_prepare.py +++ b/tools/release_prepare.py @@ -11,7 +11,7 @@ from urllib.parse import quote import release -from release_notes import parse_notes, render_notes, section, validate_direct_note +from release_notes import parse_notes, render_notes, render_unified_notes, section, validate_direct_note def maintained(module): @@ -19,16 +19,11 @@ def maintained(module): def scope_modules(modules, selected=None, all_modules=False): - if all_modules and selected: - raise ValueError("Choose --all or --module, not both.") - result = list(modules) if all_modules else list(selected or []) - if all_modules: - result = [directory for directory in result if maintained(modules[directory])] - if not result or len(result) != len(set(result)): - raise ValueError("Select unique module directories or --all.") - for directory in result: - if directory not in modules or not maintained(modules[directory]): - raise ValueError(f"Not a maintained public release module: {directory}") + if selected: + raise ValueError("Unified releases always include all maintained modules; component selection is no longer supported.") + result = [directory for directory in modules if maintained(modules[directory])] + if not result or "." not in result: + raise ValueError("Unified releases require a maintained root module.") return result @@ -160,21 +155,18 @@ def module_requirements(modules): return result -def select_dependencies(selected, modules, requirements, available): - paths = {"github.com/" + release.REPOSITORY + ("" if directory == "." else "/" + directory): directory for directory in modules} - result = list(selected) - for directory in result: - for requirement in requirements[directory]: - dependency = paths.get(requirement["Path"]) - if dependency is None: - if requirement["Path"].startswith("github.com/" + release.REPOSITORY + "/"): - raise ValueError(f"Unknown internal dependency: {requirement['Path']}") - continue - if not maintained(modules[dependency]): - raise ValueError(f"Unsupported release dependency: {dependency}") - if dependency not in result and not available(release.tag_name(dependency, requirement["Version"])): - result.append(dependency) - return result +def unified_version(modules, previous, bump, entries, reserved_tags): + current = max((module["version"] for module in modules.values() if maintained(module)), key=release.version_key) + published = [tag.rsplit("/", 1)[-1] for tag in previous.values() if tag] + baseline = max([current, *published], key=release.version_key) if published else None + reserved = [] + for tag in reserved_tags: + for directory, module in modules.items(): + prefix = "" if directory == "." else directory + "/" + version = tag.removeprefix(prefix) + if maintained(module) and tag.startswith(prefix) and release.VERSION.fullmatch(version): + reserved.append(version) + return next_version(current, baseline, bump, entries, reserved) def synchronize_metadata(modules, selected, requirements): @@ -196,6 +188,7 @@ def synchronize_metadata(modules, selected, requirements): updates[directory] = changed manifest_path = release.ROOT / "tools/modules.json" data = json.loads(manifest_path.read_text(encoding="utf-8")) + data["release_version"] = selected["."]["version"] for module in data["modules"]: if module["directory"] in selected: module["version"] = selected[module["directory"]]["version"] @@ -237,7 +230,7 @@ def preparation_body(plan, name): |---|---|---| {rows} -Requested modules: {', '.join('`' + directory + '`' for directory in plan['requested_modules'])}. Automatically included dependencies: {', '.join('`' + directory + '`' for directory in plan['included_dependencies']) or 'None'}. +Unified project version: **{plan['release_version']}**. All maintained public modules are included, even when only one component changes. The root Release groups accumulated component and repository changes and lists the full version table. Historical notes inferred from PR titles and changed paths: {history}. Review these summaries, initial scope statements, and direct-commit acknowledgements in the plan. @@ -299,7 +292,7 @@ def open_preparation(plan, name, branch, api): def prepare(args, api): modules = release.manifest() - requested = scope_modules(modules, args.module, args.all) + requested = scope_modules(modules, getattr(args, "module", None), args.all) if args.issue is not None and args.issue < 1: raise ValueError("Use a positive release tracking issue number.") target = release.git("rev-parse", "HEAD") @@ -307,7 +300,7 @@ def prepare(args, api): raise ValueError("Prepare from a clean checkout of current origin/main; no manual version edits are needed.") if args.local and not args.issue: raise ValueError("--local requires an existing --issue and creates no GitHub issue or PR.") - name = args.plan or "auto-" + hashlib.sha256((",".join(sorted(requested)) + ":" + args.bump + ":" + str(args.auto_publish)).encode()).hexdigest()[:10] + "-" + target[:12] + name = args.plan or "auto-" + hashlib.sha256(("unified:" + ",".join(sorted(requested)) + ":" + args.bump + ":" + str(args.auto_publish)).encode()).hexdigest()[:10] + "-" + target[:12] plan_path = release.plan_path(name) branch = "release/" + name if not args.local: @@ -341,17 +334,18 @@ def available(tag): available_cache[tag] = tag in published and bool(release.remote_tag(api, tag)) return available_cache[tag] requirements = module_requirements(modules) - directories = select_dependencies(requested, modules, requirements, available) + directories = requested history = History(api, modules, target, overrides) - plan = {"schema_version": 1, "issue": args.issue or 1, "source_sha": target, "auto_publish": args.auto_publish, + previous_tags = {directory: release.previous_release(directory, target, releases) for directory in directories} + histories = {directory: history.notes(previous_tags[directory]) for directory in directories} + all_entries = [entry for entries, _, _, _ in histories.values() for entry in entries] + version = unified_version(modules, previous_tags, args.bump, all_entries, reserved_tags) + plan = {"schema_version": 2, "release_version": version, "repository_entries": [entry for entry in histories["."][0] if entry["module"] == "repository"], "issue": args.issue or 1, "source_sha": target, "auto_publish": args.auto_publish, "requested_modules": requested, "included_dependencies": [directory for directory in directories if directory not in requested], "bump": args.bump, "modules": []} for directory in directories: - previous = release.previous_release(directory, target, releases) - entries, reviewed, direct, generated = history.notes(previous) + previous = previous_tags[directory] + entries, reviewed, direct, generated = histories[directory] related = [entry for entry in entries if entry["module"] == directory] - prefix = "" if directory == "." else directory + "/" - reserved = [tag[len(prefix):] for tag in reserved_tags if tag.startswith(prefix) and release.VERSION.fullmatch(tag[len(prefix):])] - version = next_version(modules[directory]["version"], previous, args.bump, related, reserved) initial = overrides.get("initial_summaries", {}).get(directory) if previous is None and not initial: initial = f"Initial public source release of the {directory if directory != '.' else 'Commons'} module. Review supported behavior and compatibility limits in the module documentation; full cross-language parity is not implied." @@ -378,12 +372,15 @@ def available(tag): for item in plan["modules"]: item["dependency_updates"] = updates.get(item["directory"], []) item["notes"] = render_notes(release.REPOSITORY, item["directory"], item["version"], item["previous_tag"], item["entries"], item["initial_summary"], item["untracked_commits"], item["dependency_updates"]) - release.validate_plan(plan, release.manifest()) + selected["."]["notes"] = render_unified_notes(release.REPOSITORY, plan) + release.validate_plan(plan, release.manifest(), release.manifest_version()) if not issue: versions = "\n".join(f"- `{item['directory']}`: `{item['version']}`" for item in plan["modules"]) - body = f"## Modules and target versions\n\n{versions}\n\n## Release scope and compatibility\n\nRequested modules: {', '.join(requested)}. Unpublished dependencies are included automatically. Review initial capability statements and compatibility boundaries in the preparation PR.\n\n## Previous versions and accumulated changes\n\nSource: `{target}`. Each module uses its own previous published tag; the reviewed plan contains accumulated PR notes, generated historical summaries, and direct-commit acknowledgements.\n\n## Publication acceptance\n\nRequire contribution, module, and documentation checks; review the plan; merge the preparation PR; publish with GoReleaser and verify fresh public Go consumers. Keep this issue open until the entire batch succeeds. Automatic publication after merge: {'enabled' if args.auto_publish else 'disabled'}." + body = f"## Unified release version\n\n{version}\n\n## Modules and target versions\n\n{versions}\n\n## Release scope and compatibility\n\nAll maintained public modules are released together. Review capability statements and compatibility boundaries in the preparation PR.\n\n## Previous versions and accumulated changes\n\nSource: `{target}`. The root Release groups accumulated component/repository notes and links the full module version table.\n\n## Publication acceptance\n\nRequire contribution, module, and documentation checks; review the plan; merge the preparation PR; publish with GoReleaser and verify fresh public Go consumers. Keep this issue open until the entire batch succeeds. Automatic publication after merge: {'enabled' if args.auto_publish else 'disabled'}." issue = api.repo("issues", method="POST", data={"title": "[Release]: " + name, "body": body}) plan["issue"] = issue["number"] + selected["."]["notes"] = render_unified_notes(release.REPOSITORY, plan) + release.validate_plan(plan, release.manifest(), release.manifest_version()) release.write_json(plan_path, plan) except (RuntimeError, ValueError, KeyError, OSError, TypeError): for path, content in before.items(): diff --git a/tools/test_release.py b/tools/test_release.py index 4196a70..50ade9e 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -43,6 +43,13 @@ def plan(items=None): return {"schema_version": 1, "issue": 20, "source_sha": A, "modules": items if items is not None else [item()]} +def unified_plan(): + data = plan([item(".", "v0.1.1", "v0.1.0"), item(), item("metrics", "v0.1.1", "metrics/v0.1.0")]) + data.update(schema_version=2, release_version="v0.1.1", repository_entries=[]) + data["modules"][0]["notes"] = release.render_unified_notes(release.REPOSITORY, data) + return data + + def pr(number, sha, base="main"): return {"number": number, "merged_at": f"2026-10-{number:02}T00:00:00Z", "merge_commit_sha": sha, "base": {"ref": base, "repo": {"full_name": release.REPOSITORY}}} @@ -69,6 +76,20 @@ def request(self, path, **kwargs): class NotesTests(unittest.TestCase): + def test_unified_summary_groups_actual_changes_and_preserves_unchanged_versions(self): + data = unified_plan() + for selected in (data["modules"][0], data["modules"][2]): + selected["entries"] = [] + data["repository_entries"] = [entry("repository", "maintenance", "Unify release versions.", 10)] + notes = release.render_unified_notes(release.REPOSITORY, data) + self.assertIn("## logger\n\n### Fixes", notes) + self.assertIn("## Repository\n\n### Maintenance", notes) + self.assertNotIn("## metrics\n", notes) + self.assertIn("| `metrics` | [v0.1.1]", notes) + self.assertIn("No component changes", notes) + self.assertEqual(notes.count("Preserve field lifetimes."), 1) + self.assertIn("/pull/10", notes) + def test_multiple_entries_and_modules(self): notes = parse_notes("## Release notes\n\n- logger | fix | Fix children.\n- metrics | feature | Add options.\n- logger | fix | Fix buffers.\n", MODULES) self.assertEqual([record["module"] for record in notes], ["logger", "metrics", "logger"]) @@ -144,6 +165,25 @@ def test_github_pagination(self): class PlanTests(unittest.TestCase): + def test_unified_internal_requirements_cannot_pin_older_components(self): + requirements = {"logger": [{"Path": "github.com/" + release.REPOSITORY, "Version": "v0.1.0"}]} + with self.assertRaisesRegex(ValueError, "cohort version"): + release.validate_unified_requirements("v0.1.1", requirements) + requirements["logger"][0]["Version"] = "v0.1.1" + requirements["logger"].append({"Path": "example.com/external", "Version": "v9.0.0"}) + release.validate_unified_requirements("v0.1.1", requirements) + + def test_unified_plans_reject_partial_mixed_and_legacy_policy_bypasses(self): + modules = {directory: {**module, "version": "v0.1.1"} for directory, module in MODULES.items()} + data = unified_plan() + self.assertEqual(set(release.validate_plan(data, modules, "v0.1.1")), {".", "logger", "metrics"}) + for change in (lambda data: data["modules"].pop(), lambda data: data["modules"][1].update(version="v0.1.2"), lambda data: data.update(schema_version=1), lambda data: data.update(release_version="v0.1.2"), lambda data: data.update(repository_entries=[entry("repository", pr=999)])): + data = unified_plan() + change(data) + with self.assertRaises(ValueError): + release.validate_plan(data, modules, "v0.1.1") + self.assertEqual(list(release.validate_plan(plan(), MODULES)), ["logger"]) + def test_frozen_notes_and_manifest_versions(self): self.assertEqual(list(release.validate_plan(plan(), MODULES)), ["logger"]) for change in (lambda data: data["modules"][0].update(version="v0.2.0"), lambda data: data["modules"][0].update(notes="Unreviewed text."), lambda data: data["modules"].append(data["modules"][0]), lambda data: data.update(source_sha="main"), lambda data: data["modules"][0].update(reviewed_prs=[])): @@ -180,6 +220,37 @@ def test_versions_and_unsafe_paths(self): class PublicationTests(unittest.TestCase): + def test_unified_summary_stays_draft_on_failure_and_finalizes_last_on_resume(self): + data, api = unified_plan(), FakeAPI() + selected = {item["directory"]: item for item in data["modules"]} + modules = {directory: {**module, "version": "v0.1.1"} for directory, module in MODULES.items()} + calls = [] + def cli(directory, selected, args, output, config, token=None, draft=True): + if token: + calls.append((directory, draft)) + tag = release.tag_name(directory, selected["version"]) + record = {"tag_name": tag, "draft": draft, "name": tag, "body": selected["notes"], "prerelease": False, "html_url": "https://example.com/" + tag} + api.responses["releases/tags/" + release.quote(tag, safe="")] = record + records = api.responses.setdefault("releases", []) + records[:] = [item for item in records if item["tag_name"] != tag] + [record] + with tempfile.TemporaryDirectory() as temporary, patch.object(release, "ROOT", Path(temporary)), patch.object(release, "preflight", return_value=(data, selected, modules, [".", "logger", "metrics"])), patch.object(release, "run", return_value="GitVersion: " + release.GORELEASER_VERSION), patch.object(release, "git", return_value=""), patch.object(release, "run_goreleaser", side_effect=cli), patch.object(release, "remote_tag", return_value=B): + Path(temporary, ".goreleaser.json").write_bytes((ROOT / ".goreleaser.json").read_bytes()) + release.write_json(release.plan_path("logger-update"), data) + def consumer(directory, *args): + if directory == "metrics": + raise RuntimeError("Consumer failed") + return {"sum": "h1:verified"} + with patch.object(release, "verify_consumer", side_effect=consumer), self.assertRaisesRegex(RuntimeError, "Consumer failed"): + release.publish(self.args(True), api) + self.assertTrue(api.responses["releases/tags/v0.1.1"]["draft"]) + self.assertFalse(any(path.startswith("issues/") for path, kwargs in api.calls if kwargs.get("method", "GET") != "GET")) + with patch.object(release, "verify_consumer", return_value={"sum": "h1:verified"}): + release.publish(self.args(True), api) + report = json.loads(Path(temporary, "dist/releases/logger-update/progress.json").read_text(encoding="utf-8")) + self.assertEqual(calls, [(".", True), ("logger", True), ("logger", False), ("metrics", True), ("metrics", False), (".", False)]) + self.assertTrue(report["completed"]) + self.assertTrue(all(item["release_published"] for item in report["modules"])) + def args(self, publish=False): return type("Args", (), {"issue": 20, "pr": 21, "sha": B, "plan": "logger-update", "publish": publish})() @@ -202,7 +273,7 @@ def test_preflight_never_writes_github_or_runs_unpublished_consumers(self): with tempfile.TemporaryDirectory() as temporary, patch.object(release, "ROOT", Path(temporary)), self.context(temporary, data), patch.object(release, "verify_consumer") as verify: release.write_json(release.plan_path("logger-update"), data) release.publish(self.args(), api) - result = json.loads((Path(temporary) / "dist/releases/logger-update/progress.json").read_text()) + result = json.loads((Path(temporary) / "dist/releases/logger-update/progress.json").read_text(encoding="utf-8")) self.assertTrue(result["completed"]) verify.assert_not_called() self.assertEqual(api.calls, []) @@ -213,7 +284,7 @@ def test_consumer_failure_keeps_tag_and_draft_and_issue_open(self): release.write_json(release.plan_path("logger-update"), data) with self.assertRaisesRegex(RuntimeError, "proxy"): release.publish(self.args(True), api) - result = json.loads((Path(temporary) / "dist/releases/logger-update/progress.json").read_text()) + result = json.loads((Path(temporary) / "dist/releases/logger-update/progress.json").read_text(encoding="utf-8")) writes = [(path, values) for path, values in api.calls if values.get("method", "GET") != "GET"] self.assertEqual([path for path, _ in writes], ["git/refs"]) self.assertTrue(api.responses["releases/tags/logger%2Fv0.1.1"]["draft"]) @@ -360,6 +431,7 @@ def git(*args): return "releases/logger-update.json\n" + ("logger/logger.go" if scenario == "library-change" else "tools/modules.json") raise AssertionError(args) args = PublicationTests().args() + release.write_json(Path(temporary) / "tools/modules.json", {"base": "github.com/" + release.REPOSITORY, "modules": list(MODULES.values())}) with patch.object(release, "ROOT", Path(temporary)), patch.object(release, "manifest", return_value=MODULES), patch.object(release, "git", side_effect=git), patch.object(release, "is_ancestor", return_value=True), patch.object(release, "run", return_value=json.dumps({"Module": {"Path": "github.com/" + release.REPOSITORY + "/logger"}, "Require": []})), patch.object(release, "check_runs", side_effect=ValueError("Check failed") if scenario == "check-failed" else None) as checks, patch.object(release, "previous_release", return_value="logger/v0.1.0"), patch.object(release, "remote_tag", return_value=None), patch.dict(os.environ, {"GITHUB_ACTOR": "maintainer"}): release.write_json(release.plan_path("logger-update"), data) if scenario == "valid": @@ -373,7 +445,7 @@ def git(*args): class WorkflowTests(unittest.TestCase): def test_publication_is_manual_and_serialized(self): - workflow = yaml.safe_load((ROOT / ".github/workflows/release.yml").read_text()) + workflow = yaml.safe_load((ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8")) events = workflow.get("on", workflow.get(True)) self.assertEqual(set(events), {"workflow_dispatch", "workflow_run"}) self.assertEqual(set(events["workflow_dispatch"]["inputs"]), {"pr", "publish"}) @@ -410,10 +482,10 @@ def test_exact_tags_frozen_notes_and_credentials_are_passed_to_cli(self): self.assertEqual(dry.args[dry.args.index("--release-notes") + 1], str(output / "tracer-otlp.md")) release.run_goreleaser("tracer/otlp", selected, args, output, config, token="explicit", draft=False) publish = command.call_args - self.assertFalse(json.loads((config.parent / "goreleaser-publish.json").read_text())["release"]["draft"]) + self.assertFalse(json.loads((config.parent / "goreleaser-publish.json").read_text(encoding="utf-8"))["release"]["draft"]) self.assertIn("--skip=validate,announce", publish.args) self.assertEqual(publish.kwargs["env"]["GITHUB_TOKEN"], "explicit") - self.assertEqual((config.parent / "publish.log").read_text(), "CLI output\n") + self.assertEqual((config.parent / "publish.log").read_text(encoding="utf-8"), "CLI output\n") @unittest.skipUnless(shutil.which("goreleaser"), "Install pinned GoReleaser OSS to run offline CLI coverage.") def test_real_cli_preserves_root_nested_and_prerelease_tags_without_publishing(self): @@ -433,17 +505,17 @@ def test_real_cli_preserves_root_nested_and_prerelease_tags_without_publishing(s selected = item(directory, version, None) subprocess.run(["git", "tag", release.tag_name(directory, version)], cwd=fixture, check=True, capture_output=True) config = release.goreleaser_config(directory, selected, output) - self.assertEqual(json.loads(config.read_text())["release"]["prerelease"], str("-" in version).lower()) + self.assertEqual(json.loads(config.read_text(encoding="utf-8"))["release"]["prerelease"], str("-" in version).lower()) note = output / (config.parent.name + ".md") note.write_text(selected["notes"], encoding="utf-8", newline="\n") release.run("goreleaser", "check", "--config", str(config), cwd=fixture, env=env) release.run_goreleaser(directory, selected, args, output, config) - metadata = json.loads((config.parent / "artifacts/metadata.json").read_text()) + metadata = json.loads((config.parent / "artifacts/metadata.json").read_text(encoding="utf-8")) self.assertEqual(metadata["tag"], release.tag_name(directory, version)) self.assertEqual(metadata["commit"], sha) - artifacts = json.loads((config.parent / "artifacts/artifacts.json").read_text()) + artifacts = json.loads((config.parent / "artifacts/artifacts.json").read_text(encoding="utf-8")) self.assertTrue(all(artifact["type"] == "Metadata" for artifact in artifacts)) - self.assertEqual(note.read_text(), selected["notes"]) + self.assertEqual(note.read_text(encoding="utf-8"), selected["notes"]) self.assertEqual(subprocess.check_output(["git", "status", "--porcelain"], cwd=fixture, text=True), "") @unittest.skipUnless(shutil.which("goreleaser"), "Install pinned GoReleaser OSS to run offline CLI coverage.") @@ -496,7 +568,7 @@ def do_PATCH(self): try: with tempfile.TemporaryDirectory(prefix="goreleaser-api-fixture-") as temporary: fixture = Path(temporary) - base = json.loads((ROOT / ".goreleaser.json").read_text()) + base = json.loads((ROOT / ".goreleaser.json").read_text(encoding="utf-8")) base["github_urls"] = {"api": f"http://127.0.0.1:{server.server_port}/", "download": "https://example.com/"} (fixture / ".goreleaser.json").write_text(json.dumps(base), encoding="utf-8") (fixture / ".gitignore").write_text("dist/\n", encoding="utf-8") @@ -506,24 +578,25 @@ def do_PATCH(self): args = type("Args", (), {"sha": sha})() output = fixture / "dist/releases/fixture" with patch.object(release, "ROOT", fixture): - for version in ("v0.1.0", "v0.1.1-rc.1"): - with self.subTest(version=version): + for directory, version in (("logger", "v0.1.0"), ("logger", "v0.1.1-rc.1"), (".", "v0.1.0"), (".", "v0.1.1-rc.1")): + with self.subTest(directory=directory, version=version): state.clear() requests.clear() - selected = item("logger", version, None) - subprocess.run(["git", "tag", release.tag_name("logger", version)], cwd=fixture, check=True, capture_output=True) - config = release.goreleaser_config("logger", selected, output) - (output / "logger.md").write_text(selected["notes"], encoding="utf-8", newline="\n") - release.run_goreleaser("logger", selected, args, output, config, token="local-fixture-token") + args.unified = directory == "." + selected = item(directory, version, None) + subprocess.run(["git", "tag", release.tag_name(directory, version)], cwd=fixture, check=True, capture_output=True) + config = release.goreleaser_config(directory, selected, output) + (output / (config.parent.name + ".md")).write_text(selected["notes"], encoding="utf-8", newline="\n") + release.run_goreleaser(directory, selected, args, output, config, token="local-fixture-token") self.assertTrue(state["release"]["draft"]) - self.assertEqual(state["release"]["name"], "logger/" + version) + self.assertEqual(state["release"]["name"], release.tag_name(directory, version)) self.assertEqual(state["release"]["prerelease"], "-" in version) self.assertEqual(state["release"]["body"].rstrip("\r\n"), selected["notes"].rstrip("\r\n")) - release.check_existing("logger", selected, sha, sha, state["release"]) - release.run_goreleaser("logger", selected, args, output, config, token="local-fixture-token", draft=False) + release.check_existing(directory, selected, sha, sha, state["release"]) + release.run_goreleaser(directory, selected, args, output, config, token="local-fixture-token", draft=False) self.assertFalse(state["release"]["draft"]) - release.check_existing("logger", selected, sha, sha, state["release"]) - self.assertEqual(state["release"]["make_latest"], "false") + release.check_existing(directory, selected, sha, sha, state["release"]) + self.assertEqual(state["release"]["make_latest"], "true" if directory == "." and "-" not in version else "false") self.assertEqual(state["release"]["target_commitish"], sha) self.assertEqual(sum(request[0] == "POST" for request in requests), 1) self.assertFalse(any(request[0] == "unexpected" for request in requests), requests) diff --git a/tools/test_release_automation.py b/tools/test_release_automation.py index bacfd56..3cc43fc 100644 --- a/tools/test_release_automation.py +++ b/tools/test_release_automation.py @@ -121,7 +121,7 @@ def fixture(): imported = required if package == "examples" else BASE + "/commons" identifier = "logger" if package == "examples" else "commons" (directory / "value.go").write_text(f'package {package}\n\nimport "{imported}"\n\nconst Value = {identifier}.Value\n', encoding="utf-8") - (root / "tools/modules.json").write_text(json.dumps({"base": BASE, "modules": modules}), encoding="utf-8") + (root / "tools/modules.json").write_text(json.dumps({"base": BASE, "release_version": "v0.1.0", "modules": modules}), encoding="utf-8") shutil.copyfile(ROOT / "tools/modules.py", root / "tools/modules.py") (root / "go.work").write_text("go 1.26\n\nuse (\n" + "\n".join("\t" + ("." if module["directory"] == "." else "./" + module["directory"]) for module in modules) + "\n)\n", encoding="utf-8") (root / ".gitignore").write_text("dist/\n__pycache__/\n", encoding="utf-8") @@ -139,10 +139,19 @@ def fixture(): def args(**changes): - return SimpleNamespace(**{**dict(module=["logger"], all=False, issue=20, plan="fixture", overrides=None, bump="auto", local=True, auto_publish=False), **changes}) + return SimpleNamespace(**{**dict(module=None, all=True, issue=20, plan="fixture", overrides=None, bump="auto", local=True, auto_publish=False), **changes}) class VersionTests(unittest.TestCase): + def test_unified_increment_uses_all_changes_and_all_reserved_component_tags(self): + with fixture(): + modules = release.manifest() + previous = {directory: release.tag_name(directory, "v0.1.0") for directory in (".", "logger", "metrics")} + self.assertEqual(preparation.unified_version(modules, previous, "auto", [{"type": "feature"}], []), "v0.2.0") + self.assertEqual(preparation.unified_version(modules, previous, "patch", [], ["metrics/v0.1.1", "tracer/xray/v0.9.0"]), "v0.1.2") + previous["logger"] = "logger/v0.2.3" + self.assertEqual(preparation.unified_version(modules, previous, "patch", [], []), "v0.2.4") + def test_automatic_and_explicit_version_policy(self): cases = [ ("v0.1.0", None, "auto", [], (), "v0.1.0"), @@ -170,13 +179,15 @@ def test_all_excludes_development_and_frozen_modules(self): class PreparationTests(unittest.TestCase): - def test_first_component_release_includes_unpublished_dependencies_and_generates_history(self): + def test_first_unified_release_includes_all_modules_and_generates_history(self): with fixture() as (root, api, source): preparation.prepare(args(auto_publish=True), api) - plan = json.loads((root / "releases/fixture.json").read_text()) - self.assertEqual(plan["requested_modules"], ["logger"]) - self.assertEqual(plan["included_dependencies"], ["."]) - self.assertEqual({item["directory"]: item["version"] for item in plan["modules"]}, {"logger": "v0.1.0", ".": "v0.1.0"}) + plan = json.loads((root / "releases/fixture.json").read_text(encoding="utf-8")) + self.assertEqual(plan["schema_version"], 2) + self.assertEqual(plan["release_version"], "v0.1.0") + self.assertEqual(plan["requested_modules"], [".", "logger", "metrics"]) + self.assertEqual(plan["included_dependencies"], []) + self.assertEqual({item["directory"]: item["version"] for item in plan["modules"]}, {"logger": "v0.1.0", ".": "v0.1.0", "metrics": "v0.1.0"}) self.assertTrue(plan["auto_publish"]) self.assertTrue(all(item["initial_summary"] for item in plan["modules"])) self.assertEqual(plan["modules"][0]["untracked_commits"][source]["description"], "Import modules") @@ -185,7 +196,7 @@ def test_first_component_release_includes_unpublished_dependencies_and_generates def test_patch_release_synchronizes_go_mod_go_work_and_development_consumers(self): with fixture() as (root, api, source): - for tag in ("v0.1.0", "logger/v0.1.0"): + for tag in ("v0.1.0", "logger/v0.1.0", "metrics/v0.1.0"): command(root, "git", "tag", tag) api.tag_shas[tag] = source api.releases.append({"tag_name": tag, "draft": False, "prerelease": False}) @@ -197,21 +208,26 @@ def test_patch_release_synchronizes_go_mod_go_work_and_development_consumers(sel pr = {"number": 11, "title": "fix(logger): fix fields", "body": "## Release notes\n\n- logger | fix | Preserve temporary fields.\n", "merged_at": "2026-10-04T00:00:00Z", "merge_commit_sha": target, "base": {"ref": "main", "repo": {"full_name": release.REPOSITORY}}} api.associations[target] = [pr] preparation.prepare(args(), api) - plan = json.loads((root / "releases/fixture.json").read_text()) - self.assertEqual([item["directory"] for item in plan["modules"]], ["logger"]) - self.assertEqual(plan["modules"][0]["version"], "v0.1.1") + plan = json.loads((root / "releases/fixture.json").read_text(encoding="utf-8")) + self.assertEqual([item["directory"] for item in plan["modules"]], [".", "logger", "metrics"]) + self.assertEqual({item["version"] for item in plan["modules"]}, {"v0.1.1"}) self.assertIn("pull/11", plan["modules"][0]["notes"]) - self.assertIn(BASE + "/logger v0.1.1", (root / "examples/go.mod").read_text()) + self.assertNotIn("pull/11", plan["modules"][2]["notes"]) + self.assertIn("Dependency alignment", plan["modules"][0]["notes"]) + self.assertIn(BASE + " v0.1.1", (root / "metrics/go.mod").read_text(encoding="utf-8")) + self.assertIn(BASE + "/logger v0.1.1", (root / "examples/go.mod").read_text(encoding="utf-8")) workspace = json.loads(command(root, "go", "work", "edit", "-json", str(root / "go.work"))) mappings = {(item["Old"]["Path"], item["Old"].get("Version")): item["New"]["Path"] for item in workspace["Replace"]} self.assertIn((BASE + "/logger", "v0.1.1"), mappings) - self.assertEqual(release.manifest()["metrics"]["version"], "v0.1.0") + self.assertEqual(release.manifest()["metrics"]["version"], "v0.1.1") + self.assertEqual(release.manifest()["tracer/xray"]["version"], "v0.1.0") + self.assertEqual(release.manifest_version(), "v0.1.1") self.assertIn("examples", plan["metadata_updates"]) def test_all_prepares_every_maintained_component(self): with fixture() as (root, api, _): preparation.prepare(args(module=None, all=True), api) - plan = json.loads((root / "releases/fixture.json").read_text()) + plan = json.loads((root / "releases/fixture.json").read_text(encoding="utf-8")) self.assertEqual({item["directory"] for item in plan["modules"]}, {".", "logger", "metrics"}) self.assertEqual(plan["included_dependencies"], []) @@ -263,11 +279,12 @@ def git(*command): self.assertEqual(command(root, "git", "rev-parse", "HEAD^1"), source) self.assertEqual(command(root, "git", "status", "--porcelain"), "") - def test_unpublished_dependencies_are_expanded_transitively(self): - modules = {directory: {"public": True} for directory in ("logger", "metrics", ".")} - requirements = {"logger": [{"Path": BASE + "/metrics", "Version": "v0.1.0"}], "metrics": [{"Path": BASE, "Version": "v0.1.0"}], ".": []} - self.assertEqual(preparation.select_dependencies(["logger"], modules, requirements, lambda tag: False), ["logger", "metrics", "."]) - self.assertEqual(preparation.select_dependencies(["logger"], modules, requirements, lambda tag: tag == "v0.1.0"), ["logger", "metrics"]) + def test_partial_request_is_rejected_before_metadata_or_github_writes(self): + with fixture() as (root, api, _): + with self.assertRaisesRegex(ValueError, "component selection"): + preparation.prepare(args(module=["logger"], all=False), api) + self.assertEqual(command(root, "git", "status", "--porcelain"), "") + self.assertEqual(api.writes, []) def test_pushed_preparation_branch_recovers_after_pr_creation_failure(self): with fixture() as (root, api, source): @@ -362,8 +379,23 @@ def repo(path, **kwargs): class CompletionTests(unittest.TestCase): + def test_legacy_schema_one_plan_can_still_be_resolved_for_recovery(self): + with fixture() as (root, api, source): + event = self.merged(root, api, source) + # Supply frozen legacy contents at the original immutable commit. + original_git = release.git + legacy = {"schema_version": 1, "issue": 20, "source_sha": source, "modules": []} + def git(*arguments): + if arguments[0] == "show": + return json.dumps(legacy) + return original_git(*arguments) + with patch.object(release, "git", side_effect=git): + resolved, data = completion.resolve_preparation(21, api) + self.assertEqual(data["schema_version"], 1) + self.assertEqual(resolved.sha, event["workflow_run"]["head_sha"]) + def merged(self, root, api, source, automatic=True): - plan = {"schema_version": 1, "issue": 20, "source_sha": source, "auto_publish": automatic, "modules": []} + plan = {"schema_version": 2, "release_version": "v0.1.0", "issue": 20, "source_sha": source, "auto_publish": automatic, "modules": []} (root / "releases").mkdir() (root / "releases/fixture.json").write_text(json.dumps(plan), encoding="utf-8") command(root, "git", "add", ".") @@ -409,10 +441,10 @@ def test_auto_publication_requires_plan_authorization_and_completed_main_checks( class WorkflowTests(unittest.TestCase): def test_preparation_and_completion_need_no_additional_secret(self): - workflow = yaml.safe_load((ROOT / ".github/workflows/prepare-release.yml").read_text()) + workflow = yaml.safe_load((ROOT / ".github/workflows/prepare-release.yml").read_text(encoding="utf-8")) events = workflow.get("on", workflow.get(True)) self.assertEqual(set(events), {"workflow_dispatch"}) - self.assertEqual(set(events["workflow_dispatch"]["inputs"]), {"scope", "bump", "auto_publish"}) + self.assertEqual(set(events["workflow_dispatch"]["inputs"]), {"bump", "auto_publish"}) self.assertTrue(events["workflow_dispatch"]["inputs"]["auto_publish"]["default"]) self.assertFalse(workflow["concurrency"]["cancel-in-progress"]) self.assertEqual(workflow["jobs"]["prepare"]["permissions"]["actions"], "write")