diff --git a/.github/scripts/release.py b/.github/scripts/release.py new file mode 100644 index 0000000..4f41b0a --- /dev/null +++ b/.github/scripts/release.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Select release versions; GoReleaser owns building and uploading artifacts.""" +import json +import os +import re +import subprocess +import urllib.request + +VERSION = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +RELEASER = re.compile(r"^ GORELEASER_VERSION: (v\d+\.\d+\.\d+)\s*$", re.M) +IGNORED = {"README.adoc", "README.md", "CLAUDE.md", "AGENTS.md", ".gitignore", "renovate.json", ".golangci.yml"} + + +def github(path, data=None): + repo = os.environ["GITHUB_REPOSITORY"] + if repo != "StackVista/stackstate-backup-cli": + raise ValueError("Release operations are restricted to StackVista/stackstate-backup-cli") + request = urllib.request.Request(f"https://api.github.com/repos/{repo}/{path}", + data=json.dumps(data).encode() if data is not None else None, + headers={"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", "Accept": "application/vnd.github+json", "Content-Type": "application/json"}, + method="POST" if data is not None else "GET") + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + + +def releases(): + result = [] + for page in range(1, 1000): + batch = github(f"releases?per_page=100&page={page}") + result.extend(batch) + if len(batch) < 100: + return result + raise ValueError("Release pagination limit exceeded") + + +def git(*args): + return subprocess.check_output(["git", *args], text=True).strip() + + +def file_at(ref, path): + result = subprocess.run(["git", "show", f"{ref}:{path}"], capture_output=True, text=True) + return result.stdout if result.returncode == 0 else "" + + +def version(tag): + match = VERSION.fullmatch(tag) + if not match: + raise ValueError(f"Invalid release version: {tag}") + return tuple(map(int, match.groups())) + + +def module_behavior(text): + text = re.sub(r"//[^\n]*", "", text) + text = re.sub(r"(?m)^[ \t]*(?:require|replace|exclude)[ \t]+\([^)]*\)", "", text) + text = re.sub(r"(?m)^[ \t]*(?:go|toolchain|require|replace|exclude)[ \t]+[^\n]*", "", text) + return text.split() + + +def classify(base, candidate): + maintenance, application = False, [] + paths = subprocess.check_output(["git", "diff", "--no-renames", "--name-only", "-z", base, candidate]).decode().split("\0") + commands = lambda text: re.findall(r"^.*\b(?:goreleaser\s+(?:build|release)|go\s+(?:build|generate)|args:\s*(?:build|release))\b.*$", + re.sub(r"\\\n[ \t]*", " ", text), re.M) + for path in filter(None, paths): + if path in {"go.mod", "go.sum"}: + if path == "go.mod" and module_behavior(file_at(base, path)) != module_behavior(file_at(candidate, path)): + application.append(path) + else: + maintenance = True + elif path in {".github/workflows/ci.yml", ".github/workflows/release.yml"}: + before, after = file_at(base, path), file_at(candidate, path) + if commands(before) != commands(after): + application.append(path) + elif RELEASER.findall(before) != RELEASER.findall(after): + maintenance = True + elif path in IGNORED or path.startswith(("docs/", ".github/")) or ("/" not in path and path.endswith(".md")): + continue + else: + application.append(path) + return maintenance, application + + +def report(message, **outputs): + print(message) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as stream: + stream.write(message + "\n\n") + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as stream: + for key, value in outputs.items(): + if "\n" in str(value): + raise ValueError("Multiline workflow output rejected") + stream.write(f"{key}={value}\n") + + +def require_ci(commit): + runs = github(f"actions/workflows/ci.yml/runs?head_sha={commit}&event=push&per_page=100")["workflow_runs"] + latest = next((run for run in runs if run["head_sha"] == commit), None) + if not latest or latest["status"] != "completed" or latest["conclusion"] != "success": + raise ValueError(f"Main CI must finish successfully for {commit} before releasing") + + +def plan(): + if os.environ["GITHUB_REF"] != "refs/heads/main": + raise ValueError("Releases must be requested from main") + entries = releases() + published = [entry for entry in entries if not entry["draft"] and not entry["prerelease"] and VERSION.fullmatch(entry["tag_name"])] + base = max(published, key=lambda entry: version(entry["tag_name"]))["tag_name"] + operation = os.environ.get("RELEASE_OPERATION") or "automatic" + tags = [tag for tag in git("tag", "--list").splitlines() if VERSION.fullmatch(tag)] + pending = sorted({tag for tag in tags + [entry["tag_name"] for entry in entries if entry["draft"]] + if VERSION.fullmatch(tag) and version(tag) > version(base)}, key=version) + if operation == "retry": + tag = os.environ["RELEASE_VERSION"] + version(tag) + if tag not in tags or tag not in pending or any(entry["tag_name"] == tag and not entry["draft"] for entry in entries): + raise ValueError("Retry requires an existing unpublished tag newer than the last release") + commit = git("rev-parse", f"{tag}^{{commit}}") + if re.search(r"^ draft: true$", file_at(commit, ".goreleaser.yml"), re.M): + raise ValueError("Legacy tags need manual recovery; their configuration requires separate draft publication") + else: + if pending: + report(f"Unfinished releases: {', '.join(pending)}. Retry or manually clean up before releasing.", eligible="false") + return + commit = os.environ.get("RELEASE_COMMIT") if operation == "automatic" else git("rev-parse", "origin/main") + if not commit or not re.fullmatch(r"[a-f0-9]{40}", commit) or subprocess.run(["git", "merge-base", "--is-ancestor", base, commit]).returncode: + report("Candidate precedes or diverges from the published release; skipping.", eligible="false") + return + maintenance, application = classify(base, commit) + if application and operation == "automatic": + report(f"Manual release required: {', '.join(application)}.", eligible="false") + return + if not maintenance and not application: + report("No releasable changes since the last published release.", eligible="false") + return + parts = list(version(base)) + bump = os.environ.get("RELEASE_BUMP") or "patch" + index = {"major": 0, "minor": 1, "patch": 2}[bump] + parts[index] += 1 + parts[index + 1:] = [0] * (2 - index) + tag = "v" + ".".join(map(str, parts)) + require_ci(commit) + pins = RELEASER.findall(file_at(commit, ".github/workflows/ci.yml")) + if len(pins) != 1: + raise ValueError("CI must declare one exact GORELEASER_VERSION") + report(f"Selected {tag} at {commit}.", eligible="true", tag=tag, commit=commit, releaser=pins[0], base=base) + + +def tag_commit(): + tag, commit = os.environ["RELEASE_VERSION"], os.environ["RELEASE_COMMIT"] + version(tag) + local_tag = subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{tag}"]).returncode == 0 + if not local_tag: + github("git/refs", {"ref": f"refs/tags/{tag}", "sha": commit}) + git("tag", tag, commit) + + +if __name__ == "__main__": + {"plan": plan, "tag": tag_commit}[__import__("sys").argv[1]]() diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py new file mode 100644 index 0000000..a595edd --- /dev/null +++ b/.github/scripts/test_release.py @@ -0,0 +1,304 @@ +import importlib.util +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location("release", Path(__file__).with_name("release.py")) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) +RELEASER = "v2.19.0" + + +class ClassifierTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.previous = Path.cwd() + os.chdir(self.directory.name) + self.git("init", "-q") + self.git("config", "user.name", "Fixture") + self.git("config", "user.email", "fixture@example.invalid") + self.write("go.mod", "module example.invalid/tool\ngo 1.26.10\nrequire example.invalid/dependency v1.0.0\n") + self.write("go.sum", "original sum\n") + self.write("main.go", "package main\n") + self.write("Makefile", "build:\n\tgo build\n") + self.write(".github/workflows/ci.yml", f"env:\n GORELEASER_VERSION: {RELEASER}\njobs:\n build:\n steps:\n - run: goreleaser release --snapshot --skip=publish\n") + self.commit() + self.base = self.tree + + def tearDown(self): + os.chdir(self.previous) + self.directory.cleanup() + + def git(self, *args): + return subprocess.check_output(["git", *args], text=True).strip() + + def write(self, name, content): + path = Path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + def commit(self): + self.git("add", ".") + self.tree = self.git("write-tree") + + def classify(self): + self.commit() + return release.classify(self.base, self.tree) + + def test_manual_dependency_repair_is_maintenance(self): + self.write("go.sum", "repaired sum\n") + self.assertEqual(self.classify(), (True, [])) + + def test_unreleased_feature_survives_later_dependency_merge(self): + self.write("main.go", "package main\nfunc feature() {}\n") + self.commit() + self.write("go.sum", "new sum\n") + self.assertEqual(self.classify(), (True, ["main.go"])) + + def test_completely_reverted_feature_does_not_block(self): + self.write("main.go", "feature\n") + self.commit() + self.write("main.go", "package main\n") + self.write("go.sum", "new sum\n") + self.assertEqual(self.classify(), (True, [])) + + def test_build_command_changed(self): + self.write("Makefile", Path("Makefile").read_text().replace("go build", "go build -race")) + self.assertEqual(self.classify(), (False, ["Makefile"])) + + def test_workflow_releaser_pin_changed(self): + path = ".github/workflows/ci.yml" + self.write(path, Path(path).read_text().replace("v2.19.0", "v2.19.1")) + self.assertEqual(self.classify(), (True, [])) + + def test_workflow_build_command_changed(self): + path = ".github/workflows/ci.yml" + self.write(path, Path(path).read_text().replace("--snapshot", "--snapshot --single-target")) + self.assertEqual(self.classify(), (False, [path])) + + def test_continued_build_flags_require_manual_selection(self): + path = ".github/workflows/release.yml" + original = "jobs:\n release:\n steps:\n - run: |\n goreleaser release \\\n --clean\n" + self.write(path, original) + self.commit() + self.base = self.tree + self.write(path, original.replace("--clean", "--clean --skip=publish")) + self.write("go.sum", "new sum\n") + self.assertEqual(self.classify(), (True, [path])) + + def test_unrelated_workflow_and_docs_do_not_release(self): + self.write(".github/workflows/renovate.yml", "name: Renovate\n") + self.write("README.md", "Documentation\n") + self.assertEqual(self.classify(), (False, [])) + + def test_unrelated_ci_command_change_does_not_release(self): + path = ".github/workflows/ci.yml" + self.write(path, Path(path).read_text() + " - run: echo new CI diagnostic\n") + self.assertEqual(self.classify(), (False, [])) + + def test_markdown_in_embedded_assets_is_application_change(self): + self.write("static/template.md", "embedded text\n") + self.assertEqual(self.classify(), (False, ["static/template.md"])) + + def test_new_release_build_command_requires_manual_selection(self): + self.write(".github/workflows/release.yml", "jobs:\n release:\n steps:\n - run: goreleaser release --skip=publish\n") + self.assertEqual(self.classify(), (False, [".github/workflows/release.yml"])) + + def test_packaging_and_templates_require_manual_selection(self): + self.write(".goreleaser.yml", "archives: []\n") + self.write("static/template.txt", "template\n") + self.assertEqual(self.classify(), (False, [".goreleaser.yml", "static/template.txt"])) + + def test_template_moved_to_docs_still_requires_manual_selection(self): + self.write("static/template.txt", "template\n") + self.commit() + self.base = self.tree + self.write("docs/template.txt", "template\n") + Path("static/template.txt").unlink() + self.write("go.sum", "new sum\n") + self.assertEqual(self.classify(), (True, ["static/template.txt"])) + + def test_module_rename_is_application_change(self): + self.write("go.mod", Path("go.mod").read_text().replace("example.invalid/tool", "example.invalid/other")) + self.assertEqual(self.classify(), (False, ["go.mod"])) + + def test_runtime_defaults_require_manual_selection(self): + self.write("go.mod", Path("go.mod").read_text() + "godebug panicnil=1\n") + self.assertEqual(self.classify(), (False, ["go.mod"])) + + def test_runtime_default_block_requires_manual_selection(self): + self.write("go.mod", Path("go.mod").read_text() + "godebug (\n panicnil=1\n)\n") + self.assertEqual(self.classify(), (False, ["go.mod"])) + + def test_dependency_and_compiler_directives_are_maintenance(self): + self.write("go.mod", "module example.invalid/tool\ngo 1.26.11\ntoolchain go1.26.11\n" + "require (\n example.invalid/dependency v1.0.1 // repaired\n)\n" + "replace example.invalid/dependency => example.invalid/fork v1.0.1\n" + "exclude (\n example.invalid/dependency v1.0.0\n)\n") + self.assertEqual(self.classify(), (True, [])) + +class SelectionTests(unittest.TestCase): + def setUp(self): + from unittest.mock import patch + self.patches = [] + self.entry = {"tag_name": "v1.2.3", "draft": False, "prerelease": False} + self.commit = "b" * 40 + self.outputs = {} + self.calls = [] + self.add(patch.dict(os.environ, {"GITHUB_REF": "refs/heads/main", "RELEASE_OPERATION": "automatic", "RELEASE_COMMIT": self.commit, "RELEASE_BUMP": "patch", "RELEASE_VERSION": "v1.2.4"})) + self.add(patch.object(release, "releases", return_value=[self.entry])) + self.add(patch.object(release, "git", side_effect=lambda *args: "v1.2.3" if args[0] == "tag" else self.commit)) + self.add(patch.object(release, "file_at", side_effect=lambda ref, path: "release: {}" if path == ".goreleaser.yml" else f"env:\n GORELEASER_VERSION: {RELEASER}\n")) + self.add(patch.object(release.subprocess, "run", return_value=type("Result", (), {"returncode": 0})())) + self.add(patch.object(release, "classify", return_value=(True, []))) + self.ci = self.add(patch.object(release, "require_ci")) + self.add(patch.object(release, "report", side_effect=lambda message, **outputs: self.outputs.update(outputs))) + + def add(self, patcher): + self.patches.append(patcher) + return patcher.start() + + def tearDown(self): + for patcher in reversed(self.patches): + patcher.stop() + + def test_automatic_patch_selection(self): + release.plan() + self.assertEqual(self.outputs["tag"], "v1.2.4") + self.assertEqual(self.outputs["commit"], self.commit) + self.assertEqual(self.outputs["releaser"], "v2.19.0") + self.ci.assert_called_once_with(self.commit) + + def test_unreleased_application_change_needs_manual_selection(self): + from unittest.mock import patch + with patch.object(release, "classify", return_value=(True, ["main.go"])): + release.plan() + self.assertEqual(self.outputs["eligible"], "false") + + def test_manual_major_selection(self): + os.environ.update(RELEASE_OPERATION="release", RELEASE_BUMP="major") + release.plan() + self.assertEqual(self.outputs["tag"], "v2.0.0") + + def test_manual_minor_selection_includes_application_changes(self): + from unittest.mock import patch + os.environ.update(RELEASE_OPERATION="release", RELEASE_BUMP="minor") + with patch.object(release, "classify", return_value=(False, ["main.go"])): + release.plan() + self.assertEqual(self.outputs["tag"], "v1.3.0") + + def test_missing_or_ambiguous_releaser_pin_blocks_publication(self): + from unittest.mock import patch + for config in ("env: {}", " GORELEASER_VERSION: latest", f" GORELEASER_VERSION: {RELEASER}\n GORELEASER_VERSION: v2.20.0"): + with self.subTest(config=config), patch.object(release, "file_at", return_value=config): + with self.assertRaisesRegex(ValueError, "exact GORELEASER_VERSION"): + release.plan() + + def test_retry_uses_releaser_pin_from_original_commit(self): + from unittest.mock import patch + os.environ["RELEASE_OPERATION"] = "retry" + with patch.object(release, "git", side_effect=lambda *args: "v1.2.3\nv1.2.4" if args[0] == "tag" else self.commit): + with patch.object(release, "file_at", side_effect=lambda ref, path: " GORELEASER_VERSION: v2.18.0\n" if path.endswith("ci.yml") else "release: {}"): + release.plan() + self.assertEqual(self.outputs["releaser"], "v2.18.0") + + def test_docs_only_manual_request_does_not_release(self): + from unittest.mock import patch + os.environ["RELEASE_OPERATION"] = "release" + with patch.object(release, "classify", return_value=(False, [])): + release.plan() + self.assertEqual(self.outputs["eligible"], "false") + + def test_unpublished_tag_blocks_new_release_but_can_be_retried(self): + from unittest.mock import patch + with patch.object(release, "git", side_effect=lambda *args: "v1.2.3\nv1.2.4" if args[0] == "tag" else self.commit): + release.plan() + self.assertEqual(self.outputs["eligible"], "false") + os.environ["RELEASE_OPERATION"] = "retry" + release.plan() + self.assertEqual(self.outputs["tag"], "v1.2.4") + self.assertEqual(self.outputs["commit"], self.commit) + + def test_published_tag_cannot_be_retried(self): + os.environ.update(RELEASE_OPERATION="retry", RELEASE_VERSION="v1.2.3") + with self.assertRaisesRegex(ValueError, "unpublished"): + release.plan() + + def test_legacy_draft_configuration_requires_manual_recovery(self): + from unittest.mock import patch + os.environ["RELEASE_OPERATION"] = "retry" + with patch.object(release, "git", side_effect=lambda *args: "v1.2.3\nv1.2.4" if args[0] == "tag" else self.commit): + with patch.object(release, "file_at", return_value=" draft: true"): + with self.assertRaisesRegex(ValueError, "manual recovery"): + release.plan() + + def test_older_legacy_draft_does_not_block(self): + from unittest.mock import patch + with patch.object(release, "releases", return_value=[self.entry, dict(self.entry, tag_name="v1.2.2", draft=True)]): + release.plan() + self.assertEqual(self.outputs["tag"], "v1.2.4") + + def test_newer_draft_without_tag_requires_manual_cleanup(self): + from unittest.mock import patch + with patch.object(release, "releases", return_value=[self.entry, dict(self.entry, tag_name="v1.2.4", draft=True)]): + release.plan() + self.assertEqual(self.outputs["eligible"], "false") + os.environ["RELEASE_OPERATION"] = "retry" + with self.assertRaisesRegex(ValueError, "existing unpublished tag"): + release.plan() + + def test_existing_local_tag_is_left_to_native_validation(self): + from unittest.mock import patch + with patch.object(release, "github") as api, patch.object(release, "git") as git: + release.tag_commit() + api.assert_not_called() + git.assert_not_called() + + def test_new_tag_is_recorded_before_native_publication(self): + from unittest.mock import patch + with patch.object(release.subprocess, "run", return_value=type("Result", (), {"returncode": 1})()): + with patch.object(release, "github") as api, patch.object(release, "git") as git: + release.tag_commit() + api.assert_called_once_with("git/refs", {"ref": "refs/tags/v1.2.4", "sha": self.commit}) + git.assert_called_once_with("tag", "v1.2.4", self.commit) + + def test_remote_tag_conflict_does_not_create_a_local_tag(self): + from unittest.mock import patch + from urllib.error import HTTPError + conflict = HTTPError("https://api.github.com", 422, "Reference already exists", {}, None) + with patch.object(release.subprocess, "run", return_value=type("Result", (), {"returncode": 1})()): + with patch.object(release, "github", side_effect=conflict), patch.object(release, "git") as git: + with self.assertRaises(HTTPError): + release.tag_commit() + git.assert_not_called() + conflict.close() + +class CIGateTests(unittest.TestCase): + def check(self, runs): + from unittest.mock import patch + with patch.object(release, "github", return_value={"workflow_runs": runs}): + release.require_ci("b" * 40) + + def run_info(self, status="completed", conclusion="success", sha="b" * 40): + return {"head_sha": sha, "status": status, "conclusion": conclusion} + + def test_successful_ci_for_candidate_allows_publication(self): + self.check([self.run_info()]) + + def test_running_ci_blocks_publication(self): + with self.assertRaisesRegex(ValueError, "CI must finish successfully"): + self.check([self.run_info(status="in_progress", conclusion=None)]) + + def test_failed_latest_rerun_blocks_despite_earlier_success(self): + with self.assertRaisesRegex(ValueError, "CI must finish successfully"): + self.check([self.run_info(conclusion="failure"), self.run_info()]) + + def test_ci_for_different_commit_does_not_suffice(self): + with self.assertRaisesRegex(ValueError, "CI must finish successfully"): + self.check([self.run_info(sha="c" * 40)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f2a5fd..4a63603 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,7 @@ on: push: branches: - main - tags: - - v* - # Allows to run this via the Actions tab workflow_dispatch: permissions: {} @@ -25,11 +22,8 @@ concurrency: jobs: lint: permissions: - # Required: allow read access to the content for analysis. contents: read - # Optional: allow read access to pull request. Use with `only-new-issues` option. pull-requests: read - # Optional: Allow write access to checks to allow the action to annotate code in the PR. checks: write name: Linter runs-on: ubuntu-latest @@ -47,9 +41,6 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - # Require: The version of golangci-lint to use. - # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. - # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. version: v2.15.0 check-license: @@ -111,9 +102,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: - # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser - # 'latest', 'nightly', or a semver version: ${{ env.GORELEASER_VERSION }} args: check @@ -134,6 +123,8 @@ jobs: with: go-version-file: go.mod cache: false + - name: Run release orchestration tests + run: python3 -m unittest discover -s .github/scripts -p "test_*.py" - name: Run tests run: | scripts/install-go-tool-test.sh @@ -195,53 +186,3 @@ jobs: dist/*_checksums.txt reports/ if-no-files-found: warn - - publish: - name: Publish the release - if: ${{ github.ref_type == 'tag' }} - needs: - - test - - security - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - env: - TAG: ${{ github.ref_name }} - S3_BUCKET: "s3://cli-dl.stackstate.com/stackstate-backup-cli/" - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - cache: false - - name: Go releaser publish - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} - with: - # either 'goreleaser' (default) or 'goreleaser-pro' - distribution: goreleaser - # 'latest', 'nightly', or a semver - version: ${{ env.GORELEASER_VERSION }} - args: release - - name: Write latest version to file - run: mkdir -p dist && echo "${TAG}" > dist/LATEST_VERSION - - - name: Authenticate with AWS - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_DEFAULT_REGION }} - - - name: Publish latest version to S3 - run: | - echo "aws s3 cp dist/LATEST_VERSION ${S3_BUCKET}" - aws s3 cp dist/LATEST_VERSION "${S3_BUCKET}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d266476 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +name: Release + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + inputs: + operation: + description: Create a release or retry an unpublished tag + type: choice + options: [release, retry] + default: release + bump: + description: Version increment for a new release + type: choice + options: [patch, minor, major] + default: patch + version: + description: Unpublished version to retry (vX.Y.Z) + type: string + default: "" + +permissions: {} + +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + concurrency: + group: stackstate-backup-cli-release + cancel-in-progress: false + queue: max + timeout-minutes: 20 + permissions: + contents: write + actions: read + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_OPERATION: ${{ github.event_name == 'workflow_dispatch' && inputs.operation || 'automatic' }} + RELEASE_COMMIT: ${{ github.sha }} + RELEASE_BUMP: ${{ inputs.bump || 'patch' }} + RELEASE_VERSION: ${{ inputs.version }} + GOTOOLCHAIN: local + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: automation + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Select fixed commit and version + id: plan + working-directory: automation + run: python3 .github/scripts/release.py plan + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: steps.plan.outputs.eligible == 'true' + with: + ref: ${{ steps.plan.outputs.commit }} + fetch-depth: 0 + path: source + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + if: steps.plan.outputs.eligible == 'true' + with: + cache: false + go-version-file: source/go.mod + + - name: Create the tag without moving an existing tag + if: steps.plan.outputs.eligible == 'true' + working-directory: source + env: + RELEASE_VERSION: ${{ steps.plan.outputs.tag }} + RELEASE_COMMIT: ${{ steps.plan.outputs.commit }} + run: python3 ../automation/.github/scripts/release.py tag + + - name: Authenticate with AWS + if: steps.plan.outputs.eligible == 'true' + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_DEFAULT_REGION }} + + - name: GoReleaser GitHub and S3 publication + if: steps.plan.outputs.eligible == 'true' + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: ${{ steps.plan.outputs.releaser }} + workdir: source + args: release --clean --parallelism 1 + env: + GORELEASER_CURRENT_TAG: ${{ steps.plan.outputs.tag }} + GORELEASER_PREVIOUS_TAG: ${{ steps.plan.outputs.base }} + + - name: Publish latest version to S3 + if: steps.plan.outputs.eligible == 'true' + working-directory: source + env: + TAG: ${{ steps.plan.outputs.tag }} + S3_BUCKET: s3://cli-dl.stackstate.com/stackstate-backup-cli/ + run: | + printf '%s\n' "$TAG" > dist/LATEST_VERSION + aws s3 cp dist/LATEST_VERSION "$S3_BUCKET" diff --git a/.gitignore b/.gitignore index 7f29574..a3a0693 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ release-notes.md release-notes.json .localdev/ + +__pycache__/ +*.pyc diff --git a/.goreleaser.yml b/.goreleaser.yml index 89faa40..6c234d6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,6 +5,8 @@ release: owner: stackvista name: stackstate-backup-cli name_template: '{{.Tag}}' + use_existing_draft: true + replace_existing_artifacts: true before: hooks: - go mod download diff --git a/README.md b/README.md index 266f1b5..e42e998 100644 --- a/README.md +++ b/README.md @@ -451,11 +451,28 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed information about the layere ## CI/CD -This project uses GitHub Actions and GoReleaser for automated releases: - -1. Push a new tag (e.g., `v1.0.0`) -2. GitHub Actions automatically builds binaries for multiple platforms -3. GoReleaser creates a GitHub release and uploads artifacts to S3 +The **Release** workflow runs daily at 06:00 UTC and batches dependency/compiler-only +changes since the last published release into a patch release, including manual +dependency repairs. Application, embedded-script and packaging changes require a +manual release. CI/docs changes alone produce no release. + +For an immediate release, run **Release** on `main` with operation `release` and +a `patch`, `minor` or `major` bump. Automatic and manual runs share one publication +lock and require successful main CI for the selected commit, including lint, +tests, license checks, GoReleaser configuration and artifact security checks. +The release uses that commit's Go compiler and GoReleaser version, creates its tag, +and publishes GitHub and S3 artifacts through GoReleaser. GitHub stays in draft +until artifact uploads complete; the S3 `LATEST_VERSION` file is updated afterward. +Direct tag pushes do not publish. +The first release after adopting this workflow needs a manual bump because the +publication configuration changed. + +For a failed release, run **Release** with operation `retry` and its unpublished +`vX.Y.Z` in `version`. The retry rebuilds the original tagged commit and replaces +incomplete draft assets; archive bytes may differ. New releases wait while a +newer unpublished tag or draft exists. Recover it or manually clean up its draft +and tag. Published releases and tags are never replaced. If only the final +`LATEST_VERSION` upload fails after publication, repair that file separately. ### Dependency updates @@ -486,7 +503,7 @@ GitHub requires the existing CI checks and an approving review before merging. Compiler patches use native automerge after artifact validation and App approval. Library updates include direct and indirect modules. Module majors and Go release-line migrations require individual manual review. -Dependency updates reach published binaries through the existing tag release process. +Dependency updates reach published binaries through the daily maintenance release workflow. The approval workflow calls the pinned public `renovate-config` action directly. The separate approval App supplies the review token.