Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 25 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Release

on:
push:
branches: [master]
paths: [VERSION]
tags: ["v*"]
workflow_dispatch:
inputs:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@
# CLI builds and GoReleaser output
/tclient
/dist/

# Python release and PTY checks
__pycache__/
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v0.1.0
64 changes: 49 additions & 15 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.

Expand Down
60 changes: 60 additions & 0 deletions scripts/release_target.py
Original file line number Diff line number Diff line change
@@ -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<prerelease>[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())
Loading
Loading