From 7f9a720970f7dc45509803cea1e68d388abf248b Mon Sep 17 00:00:00 2001 From: Eric_Lee Date: Wed, 4 Nov 2026 06:51:43 +0800 Subject: [PATCH] build: release v0.1.0 through reviewed version PRs --- .github/workflows/release.yml | 51 ++++---- .github/workflows/test.yml | 3 + .gitignore | 3 + CHANGELOG.md | 3 + README.md | 14 ++- VERSION | 1 + docs/releasing.md | 64 +++++++--- scripts/release_target.py | 60 +++++++++ scripts/test_release_target.py | 221 +++++++++++++++++++++++++++++++++ 9 files changed, 374 insertions(+), 46 deletions(-) create mode 100644 VERSION create mode 100644 scripts/release_target.py create mode 100644 scripts/test_release_target.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1f84c7..0330a10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,8 @@ name: Release on: push: + branches: [master] + paths: [VERSION] tags: ["v*"] workflow_dispatch: inputs: @@ -13,23 +15,37 @@ on: permissions: contents: read -concurrency: - group: release-${{ inputs.tag || github.ref_name }} - cancel-in-progress: false - jobs: + prepare: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.target.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Resolve and validate the release version + id: target + env: + RELEASE_INPUT_TAG: ${{ inputs.tag }} + run: python3 scripts/release_target.py >> "$GITHUB_OUTPUT" + test: + needs: prepare uses: ./.github/workflows/test.yml publish: - needs: test + needs: [prepare, test] runs-on: ubuntu-latest + concurrency: + group: release-${{ needs.prepare.outputs.tag }} + cancel-in-progress: false permissions: contents: write id-token: write attestations: write env: - RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -39,27 +55,10 @@ jobs: - name: Validate the release target shell: bash run: | - if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then - echo "Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix." >&2 - exit 1 - fi - if [[ "$RELEASE_TAG" == *-* ]]; then - IFS='.' read -ra identifiers <<< "${RELEASE_TAG#*-}" - for identifier in "${identifiers[@]}"; do - if [[ "$identifier" =~ ^0[0-9]+$ ]]; then - echo "Numeric prerelease identifiers must not have leading zeroes." >&2 - exit 1 - fi - done - fi - if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$GITHUB_REF" != refs/heads/master ]]; then - echo "Run manual releases from master." >&2 - exit 1 - fi git merge-base --is-ancestor HEAD origin/master if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$(git rev-parse HEAD)" - elif [[ "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then + elif [[ "$GITHUB_REF" != refs/heads/master ]]; then echo "The pushed release tag must already exist." >&2 exit 1 fi @@ -68,8 +67,8 @@ jobs: echo "This version is already published. Create a new version instead of overwriting it." >&2 exit 1 fi - - name: Create the annotated tag for a manual release - if: github.event_name == 'workflow_dispatch' + - name: Create the annotated tag for a release from master + if: github.ref == 'refs/heads/master' shell: bash run: | if ! git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61cc2d2..4f055f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,9 @@ jobs: go-version: ${{ matrix.go-version }} - run: go test -race -timeout=3m ./... - run: go vet ./... + - name: Verify release version selection + if: runner.os == 'Linux' + run: python3 -m unittest discover -s scripts -p 'test_release_target.py' - name: Verify interactive CLI with a real PTY and local TCP server if: runner.os == 'Linux' run: | diff --git a/.gitignore b/.gitignore index 74dcb50..4c4d6dc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ # CLI builds and GoReleaser output /tclient /dist/ + +# Python release and PTY checks +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bb14f80..1afc7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ the library redesign since `v0.0.2`, with the incompatible changes below. - Archives for Linux, macOS, and Windows on amd64 and arm64, with SHA-256 checksums and GitHub build attestations. Unix supports interactive terminals; Windows currently supports redirected sessions only. +- Version PRs that publish after a `VERSION` change is merged into `master` + and release checks pass. Manual workflow runs and version-tag pushes remain + available as alternative release entry points. - A stateful login matcher with bounded buffering, complete-line prompt matching, password-change detection, and preservation of data received after the shell prompt. Partial failure messages and banner text no longer count diff --git a/README.md b/README.md index 239459d..df449b4 100644 --- a/README.md +++ b/README.md @@ -298,11 +298,15 @@ actual network packet layout, retransmission, or timing is relevant. ## Releases -The Go library and `cmd/tclient` share one semantic-version tag. The release -workflow runs the test matrix, builds all six platform archives, uploads a draft, -and publishes it only after build attestations succeed. Maintainers can start a -release from GitHub Actions or push a version tag; see the -[release guide](docs/releasing.md). +The Go library and `cmd/tclient` share one semantic-version tag. To release, +open a PR that updates `VERSION` and [CHANGELOG.md](CHANGELOG.md). +Merging the version change into `master` starts the release workflow for that +commit. The workflow runs the test matrix, builds all six platform archives, +uploads a draft, and publishes it only after build attestations succeed. + +PR checks and ordinary commits that leave `VERSION` unchanged do not publish a +release. Manual GitHub Actions runs and pushed version tags are also supported; +see the [release guide](docs/releasing.md). ## Scope and privilege switching diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..b82608c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v0.1.0 diff --git a/docs/releasing.md b/docs/releasing.md index eb7ce12..c44e99e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -16,18 +16,35 @@ Update [CHANGELOG.md](../CHANGELOG.md) with the new version's user-visible changes and migration steps before merging the release commit. Generated commit lists supplement these notes; they do not replace API upgrade guidance. -In GitHub, open **Actions → Release → Run workflow**, select `master`, and -enter the version tag. For example, use `v0.1.0` for a stable release or -`v0.1.0-rc.1` for a release candidate. +### Recommended: merge a version PR -The workflow tests the exact commit selected when it starts. After the tests -pass, it creates an annotated tag if that tag does not already exist. An -existing tag must point to the same commit. It then builds and publishes from -that commit, even if `master` advances while the workflow is running. A tag -created with the workflow's `GITHUB_TOKEN` does not start another workflow; -the same run continues the release. +1. Set `VERSION` to the intended tag on a single line, for example + `v0.1.0` or `v0.1.0-rc.1`, and update the changelog. +2. Open a PR describing the version, release highlights, and any migration + steps. Review its checks and snapshot archives. +3. Merge the reviewed PR into `master`. The push that changes `VERSION` starts + the `Release` workflow automatically. -Alternatively, push an annotated tag from a reviewed checkout: +**The initial `VERSION` value is `v0.1.0`. Merging the PR that introduces this +file starts the `v0.1.0` release after the release checks pass.** + +Opening or updating a PR only runs checks and creates snapshot artifacts. +Ordinary pushes to `master` that leave `VERSION` unchanged do not start a +release. A direct push to `master` that changes `VERSION` also starts one, so +treat a version-file change as an explicit request to publish. + +`VERSION` records the requested release version, not whether publication has +completed. It does not change the version reported by ordinary developer +builds. Release binaries receive metadata from GoReleaser; ordinary builds and +`go install` continue to use their embedded module/VCS metadata where available. + +### Other entry points + +For a manual run, open **Actions → Release → Run workflow**, select `master`, +and enter the version tag. The entered tag is used instead of `VERSION`. + +Alternatively, push an annotated tag from a reviewed checkout already merged +into `master`: ```bash git switch master @@ -36,8 +53,25 @@ git tag -a v0.1.0 -m "v0.1.0" git push origin v0.1.0 ``` -Choose one entry point for a version. Both run the same tests and release job. -The tag-push path uses the existing tag and never moves it. +The pushed tag is used instead of `VERSION`; this path never creates or moves +a tag. Choose one entry point for each version. + +### Commit and tag guarantees + +All three entry points resolve and validate the version, then run the same +tests and release job. The workflow uses the exact commit selected when it +starts: a version-file release reads `VERSION` from that commit, not from a +later `master`. Testing, tagging, building, and publication remain on that +commit even if `master` advances during the run. + +After the tests pass, version-file and manual releases create an annotated +tag if it does not already exist. An existing tag must point to the same +commit. A tag created with the workflow's `GITHUB_TOKEN` does not start another +workflow; the same run continues the release. + +Publication is serialized by the resolved version across all three entry +points. A published version cannot be overwritten, even if another entry point +is used to request it again. ## What gets published @@ -53,9 +87,9 @@ build time, Go compiler version, and target platform. | macOS | amd64, arm64 | `tclient_VERSION_darwin_ARCH.tar.gz` | | Windows | amd64, arm64 | `tclient_VERSION_windows_ARCH.zip` | -Each archive contains the binary, README, changelog, and documentation guides. The release also -contains `checksums.txt` with SHA-256 hashes. Windows binaries support -redirected sessions; interactive terminal support currently targets Unix. +Each archive contains the binary, README, changelog, and documentation guides. +The release also contains `checksums.txt` with SHA-256 hashes. Windows binaries +support redirected sessions; interactive terminal support currently targets Unix. Cross-compilation does not replace platform runtime testing: native CI runs on Linux and Windows amd64, and the PTY suite runs on Linux. diff --git a/scripts/release_target.py b/scripts/release_target.py new file mode 100644 index 0000000..8648e33 --- /dev/null +++ b/scripts/release_target.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Resolve one validated release tag from the workflow's fixed input commit.""" + +import os +from pathlib import Path +import re +import sys + + +VERSION_PATTERN = re.compile( + r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + r"(?:-(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" +) + + +def resolve_tag( + event_name: str, ref: str, requested_tag: str, version_file: Path = Path("VERSION") +) -> str: + """Keep version selection independent from tagging or publishing rights.""" + if event_name == "workflow_dispatch": + if ref != "refs/heads/master": + raise ValueError("Run manual releases from master.") + tag = requested_tag + elif event_name == "push" and ref == "refs/heads/master": + lines = version_file.read_text(encoding="utf-8").splitlines() + if len(lines) != 1: + raise ValueError("VERSION must contain exactly one version tag.") + tag = lines[0] + elif event_name == "push" and ref.startswith("refs/tags/"): + tag = ref.removeprefix("refs/tags/") + else: + raise ValueError("Release requires a master version change, version tag, or manual run.") + + match = VERSION_PATTERN.fullmatch(tag) + if match is None: + raise ValueError("Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix.") + prerelease = match.group("prerelease") + if prerelease: + for identifier in prerelease.split("."): + if identifier.isdigit() and len(identifier) > 1 and identifier[0] == "0": + raise ValueError("Numeric prerelease identifiers must not have leading zeroes.") + return tag + + +def main(): + try: + tag = resolve_tag( + os.environ.get("GITHUB_EVENT_NAME", ""), + os.environ.get("GITHUB_REF", ""), + os.environ.get("RELEASE_INPUT_TAG", ""), + ) + except (OSError, UnicodeError, ValueError) as error: + print(f"Invalid release target: {error}", file=sys.stderr) + return 1 + print(f"tag={tag}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_release_target.py b/scripts/test_release_target.py new file mode 100644 index 0000000..b53a70f --- /dev/null +++ b/scripts/test_release_target.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Regression tests for release entry points and GitHub output boundaries. + +Run with: python3 -m unittest discover -s scripts -p 'test_release_target.py' +Only temporary local files and subprocesses are used; no tags or releases are +created by this suite. +""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from release_target import resolve_tag + + +SCRIPT = Path(__file__).with_name("release_target.py").resolve() + + +class ReleaseTargetTests(unittest.TestCase): + def setUp(self): + workspace = tempfile.TemporaryDirectory() + self.addCleanup(workspace.cleanup) + self.workspace = Path(workspace.name) + self.version_file = self.workspace / "VERSION" + + def resolve(self, event, ref, requested=""): + return resolve_tag(event, ref, requested, self.version_file) + + def write_version(self, content): + self.version_file.write_bytes(content.encode("utf-8")) + + def run_script(self, event, ref, requested=""): + env = os.environ.copy() + env.update( + GITHUB_EVENT_NAME=event, + GITHUB_REF=ref, + RELEASE_INPUT_TAG=requested, + ) + return subprocess.run( + [sys.executable, str(SCRIPT)], + cwd=self.workspace, + env=env, + text=True, + capture_output=True, + timeout=5, + check=False, + ) + + def test_checked_in_version_is_a_valid_release_target(self): + # Reject an invalid version in the actual PR, not just in fixtures. + version_file = Path(__file__).resolve().parents[1] / "VERSION" + resolve_tag("push", "refs/heads/master", "", version_file) + + def test_master_push_uses_version_from_the_selected_checkout(self): + self.write_version("v0.1.0\n") + self.assertEqual( + self.resolve("push", "refs/heads/master", "v9.9.9"), "v0.1.0" + ) + + def test_manual_release_uses_explicit_input_without_reading_version(self): + for version in (None, "invalid\nsecond line\n", "v9.9.9\n"): + with self.subTest(version=version): + if version is not None: + self.write_version(version) + self.assertEqual( + self.resolve("workflow_dispatch", "refs/heads/master", "v0.1.0"), + "v0.1.0", + ) + + def test_tag_push_uses_the_ref_without_reading_version(self): + for version in (None, "invalid\nsecond line\n", "v9.9.9\n"): + with self.subTest(version=version): + if version is not None: + self.write_version(version) + self.assertEqual( + self.resolve("push", "refs/tags/v0.1.0-rc.1", "v9.9.9"), + "v0.1.0-rc.1", + ) + + def test_unapproved_events_and_refs_are_rejected(self): + self.write_version("v0.1.0\n") + cases = ( + ("pull_request", "refs/pull/13/merge"), + ("pull_request_target", "refs/heads/master"), + ("workflow_call", "refs/heads/master"), + ("", "refs/heads/master"), + ("push", "refs/heads/feature"), + ("push", "refs/heads/master/child"), + ("push", "refs/heads/v0.1.0"), + ("push", "master"), + ("push", ""), + ("workflow_dispatch", "refs/heads/feature"), + ("workflow_dispatch", "refs/tags/v0.1.0"), + ("workflow_dispatch", "master"), + ) + for event, ref in cases: + with self.subTest(event=event, ref=ref): + with self.assertRaises(ValueError): + self.resolve(event, ref, "v0.1.0") + + def test_supported_versions_work_through_all_three_entry_points(self): + versions = ( + "v0.0.0", + "v0.1.0", + "v10.20.30", + "v1.2.3-rc.1", + "v1.2.3-0", + "v1.2.3-alpha.beta.7", + "v1.2.3-alpha-beta.01a", + ) + for tag in versions: + with self.subTest(tag=tag): + self.write_version(tag + "\n") + self.assertEqual(self.resolve("push", "refs/heads/master"), tag) + self.assertEqual(self.resolve("push", "refs/tags/" + tag), tag) + self.assertEqual( + self.resolve("workflow_dispatch", "refs/heads/master", tag), tag + ) + + def test_invalid_versions_cannot_enter_through_an_alternative_trigger(self): + versions = ( + "", + "0.1.0", + "V0.1.0", + "v1.2", + "v1.2.3.4", + "v01.2.3", + "v1.02.3", + "v1.2.03", + "v1.2.3-01", + "v1.2.3-rc.01", + "v1.2.3-", + "v1.2.3-.rc", + "v1.2.3-rc.", + "v1.2.3-rc..1", + "v1.2.3-rc_1", + "v1.2.3+build.1", + " v1.2.3", + "v1.2.3 ", + "v1.2.3\t", + "v1.2.3", + "v١.2.3", + "v1.2.3-候选.1", + "v1.2.3/extra", + "v1.2.3; echo unexpected", + "v1.2.3$(echo unexpected)", + "v1.2.3\ncreate_tag=true", + "v1.2.3\rcreate_tag=true", + ) + for tag in versions: + self.write_version(tag + "\n") + for event, ref, requested in ( + ("push", "refs/heads/master", ""), + ("push", "refs/tags/" + tag, ""), + ("workflow_dispatch", "refs/heads/master", tag), + ): + with self.subTest(tag=tag, event=event, ref=ref): + with self.assertRaises(ValueError): + self.resolve(event, ref, requested) + + def test_version_file_accepts_normal_single_line_endings(self): + for ending in ("", "\n", "\r\n"): + with self.subTest(ending=repr(ending)): + self.write_version("v0.1.0" + ending) + self.assertEqual(self.resolve("push", "refs/heads/master"), "v0.1.0") + + def test_empty_or_multiline_version_files_are_rejected(self): + for content in ("", "\n", "v0.1.0\n\n", "\nv0.1.0\n", "v0.1.0\nv0.2.0\n"): + with self.subTest(content=content): + self.write_version(content) + with self.assertRaises(ValueError): + self.resolve("push", "refs/heads/master") + + def test_master_push_requires_a_version_file(self): + with self.assertRaises(OSError): + self.resolve("push", "refs/heads/master") + + def test_script_emits_only_one_output_assignment_for_each_entry_point(self): + self.write_version("v0.1.0\r\n") + for event, ref, requested in ( + ("push", "refs/heads/master", "v9.9.9"), + ("push", "refs/tags/v0.1.0", "v9.9.9"), + ("workflow_dispatch", "refs/heads/master", "v0.1.0"), + ): + with self.subTest(event=event, ref=ref): + result = self.run_script(event, ref, requested) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "tag=v0.1.0\n") + self.assertEqual(result.stderr, "") + + def test_script_errors_do_not_write_partial_or_injected_outputs(self): + self.write_version("v0.1.0\n") + for event, ref, requested in ( + ("workflow_dispatch", "refs/heads/master", "v0.1.0\ntrusted=true"), + ("workflow_dispatch", "refs/heads/master", ""), + ("workflow_dispatch", "refs/heads/feature", "v0.1.0"), + ("push", "refs/tags/v0.1.0\ntrusted=true", ""), + ("pull_request", "refs/heads/master", "v0.1.0"), + ): + with self.subTest(event=event, ref=ref, requested=requested): + result = self.run_script(event, ref, requested) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + self.assertTrue(result.stderr) + + def test_script_reports_missing_or_multiline_version_without_output(self): + for content in (None, "v0.1.0\ntrusted=true\n"): + with self.subTest(content=content): + if content is not None: + self.write_version(content) + result = self.run_script("push", "refs/heads/master") + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + self.assertTrue(result.stderr) + + +if __name__ == "__main__": + unittest.main()