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
1 change: 1 addition & 0 deletions .github/release-request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"tag":"v2.0.15"}
97 changes: 97 additions & 0 deletions .github/scripts/request_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Publish a requested tag only after CI for this exact main commit succeeds."""
import json
import os
from pathlib import Path
import re
import time
import urllib.error
import urllib.request


def main():
repo = os.environ["GITHUB_REPOSITORY"]
sha = os.environ["GITHUB_SHA"]
token = os.environ["GH_TOKEN"]
if os.environ["GITHUB_REF"] != "refs/heads/main":
raise RuntimeError("Release requests must run on main")
tag = json.loads(Path(".github/release-request.json").read_text())["tag"]
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", tag):
raise RuntimeError("Invalid release tag")
if not any(line == "## " + tag or line.startswith("## " + tag + " ")
for line in Path("CHANGELOG.md").read_text(encoding="utf-8").splitlines()):
raise RuntimeError("Requested tag has no changelog section")

def api(path, method="GET", data=None, missing_ok=False):
request = urllib.request.Request(
f"https://api.github.com/repos/{repo}/{path}",
data=None if data is None else json.dumps(data).encode(),
method=method,
headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as error:
if error.code == 404 and missing_ok:
return None
raise

required = {".github/workflows/ci.yml", ".github/workflows/windows-gui.yml"}
deadline = time.monotonic() + 30 * 60
while True:
runs = api(f"actions/runs?head_sha={sha}&event=push&per_page=100")["workflow_runs"]
latest = {}
for run in sorted(runs, key=lambda r: r["id"], reverse=True):
if run["path"] in required:
latest.setdefault(run["path"], run)
for run in latest.values():
if run["status"] == "completed" and run["conclusion"] != "success":
raise RuntimeError(f"Release blocked by {run['name']}: {run['conclusion']} ({run['html_url']})")
if required == set(latest) and all(r["conclusion"] == "success" for r in latest.values()):
break
if time.monotonic() > deadline:
raise TimeoutError("Timed out waiting for Linux and Windows CI")
print("Waiting for Linux and Windows CI for " + sha, flush=True)
time.sleep(20)

existing = api("git/ref/tags/" + tag, missing_ok=True)
if existing is not None:
if existing["object"]["type"] != "commit" or existing["object"]["sha"] != sha:
raise RuntimeError("Refusing to move an existing tag")
else:
api("git/refs", "POST", {"ref": "refs/tags/" + tag, "sha": sha})

release = api("releases/tags/" + tag, missing_ok=True)
if release is None:
before = {r["id"] for r in api("actions/workflows/release.yml/runs?event=workflow_dispatch&per_page=100")["workflow_runs"]}
# Events made with GITHUB_TOKEN do not recursively trigger a tag-push
# workflow. Explicit dispatch starts the existing Release workflow.
api("actions/workflows/release.yml/dispatches", "POST", {"ref": tag, "inputs": {"tag": tag}})
deadline = time.monotonic() + 30 * 60
while True:
runs = api(f"actions/workflows/release.yml/runs?event=workflow_dispatch&head_sha={sha}&per_page=100")["workflow_runs"]
candidates = [r for r in runs if r["id"] not in before and r["head_branch"] == tag]
if candidates:
run = max(candidates, key=lambda r: r["id"])
if run["status"] == "completed":
if run["conclusion"] != "success":
raise RuntimeError(f"Release failed: {run['html_url']}")
break
if time.monotonic() > deadline:
raise TimeoutError("Timed out waiting for Release workflow")
print("Waiting for existing Release workflow: " + tag, flush=True)
time.sleep(20)
release = api("releases/tags/" + tag)
if release["draft"]:
raise RuntimeError("Release is still a draft")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"tag={tag}\n")
print("Published release: " + release["html_url"], flush=True)


if __name__ == "__main__":
main()
108 changes: 108 additions & 0 deletions .github/workflows/request-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: Requested release and binary verification

on:
push:
branches: [main]
paths: [.github/release-request.json]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: requested-release
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 65
permissions:
contents: write
actions: write
outputs:
tag: ${{ steps.release.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Wait for CI, tag the exact commit and run the existing Release workflow
id: release
env:
GH_TOKEN: ${{ github.token }}
run: python3 .github/scripts/request_release.py
- name: Download and smoke-test the published Linux amd64 binary
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
shell: bash
run: |
set -euo pipefail
version="${TAG#v}"
mkdir validation-linux
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir validation-linux \
--pattern "flyssh-${version}-linux-amd64.tar.gz" --pattern checksums.txt
cd validation-linux
sha256sum --check --ignore-missing checksums.txt
tar -xzf "flyssh-${version}-linux-amd64.tar.gz"
"./flyssh-${version}-linux-amd64" --version | tee version.txt
grep -F "$version" version.txt
- uses: actions/upload-artifact@v4
if: always()
with:
name: published-linux-smoke-${{ github.sha }}
path: validation-linux/*.txt
if-no-files-found: ignore

verify-windows:
needs: publish
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download, verify and extract the published Windows amd64 binary
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.publish.outputs.tag }}
run: |
$version = $env:TAG.Substring(1)
$archive = "flyssh-$version-windows-amd64.exe.zip"
New-Item -ItemType Directory -Path validation/downloaded -Force | Out-Null
gh release download $env:TAG --repo $env:GITHUB_REPOSITORY --dir validation/downloaded --pattern $archive --pattern checksums.txt
if ($LASTEXITCODE -ne 0) { throw 'Release download failed' }
$lines = @(Get-Content validation/downloaded/checksums.txt | Where-Object { $_.EndsWith(" $archive") })
if ($lines.Count -ne 1) { throw 'Release checksum entry missing or duplicated' }
$expected = $lines[0].Split(' ')[0]
$actual = (Get-FileHash "validation/downloaded/$archive" -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw 'Published Windows ZIP checksum mismatch' }
"$actual $archive" | Set-Content validation/verified-checksum.txt
Expand-Archive "validation/downloaded/$archive" -DestinationPath validation/extracted
$binary = (Resolve-Path "validation/extracted/flyssh-$version-windows-amd64.exe").Path
"FLYSSH_GUI_BINARY=$binary" >> $env:GITHUB_ENV
"FLYSSH_GUI_TEST_ARTIFACTS=$env:GITHUB_WORKSPACE/validation" >> $env:GITHUB_ENV
$versionOutput = & $binary --version
if ($LASTEXITCODE -ne 0 -or -not ($versionOutput -match [regex]::Escape($version))) { throw 'Published binary version mismatch' }
$versionOutput | Set-Content validation/version.txt
- name: Test the downloaded release through native GUI controls and real SSH
shell: pwsh
run: |
go test ./e2e -run '^TestWindowsGUIHashes$' -count=1 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- uses: actions/upload-artifact@v4
if: always()
with:
name: published-windows-validation-${{ github.sha }}
path: |
validation/*.txt
validation/*.log
validation/*.png
if-no-files-found: error
retention-days: 30
90 changes: 90 additions & 0 deletions .github/workflows/windows-gui.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Windows GUI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
packaged-gui:
runs-on: windows-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Build embedded relay binaries
shell: bash
run: |
set -euo pipefail
relay_dir="pkg/forwarding/relaybin"
mkdir -p "${relay_dir}"
targets=("linux amd64" "linux arm64" "linux 386" "linux arm 6" "darwin amd64" "darwin arm64" "freebsd amd64")
for t in "${targets[@]}"; do
read -r goos goarch goarm <<<"${t}"
name="relay-${goos}-${goarch}"
GOOS="${goos}" GOARCH="${goarch}" GOARM="${goarm:-6}" CGO_ENABLED=0 \
go build -trimpath -ldflags "-s -w" -o "${relay_dir}/${name}" ./cmd/relay
gzip -9 -c "${relay_dir}/${name}" > "${relay_dir}/${name}.gz"
rm "${relay_dir}/${name}"
done

- name: Windows GUI unit tests
run: go test ./pkg/wingui -v -count=1 -timeout 3m

- name: Build Windows release-mode executables
shell: bash
run: |
set -euo pipefail
go install github.com/akavel/rsrc@latest
mkdir -p dist validation
version="dev-${GITHUB_SHA:0:12}"
for arch in amd64 arm64; do
rsrc -manifest flyssh.manifest -arch "${arch}" -o "rsrc_windows_${arch}.syso"
GOOS=windows GOARCH="${arch}" CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.Version=${version}" -o "dist/flyssh-windows-${arch}.exe" .
done

- name: Package and extract the binary under test
shell: pwsh
run: |
foreach ($arch in @('amd64', 'arm64')) {
Compress-Archive -Path "dist/flyssh-windows-$arch.exe" -DestinationPath "dist/flyssh-windows-$arch.exe.zip"
}
Expand-Archive -Path dist/flyssh-windows-amd64.exe.zip -DestinationPath validation/extracted
$binary = (Resolve-Path validation/extracted/flyssh-windows-amd64.exe).Path
"FLYSSH_GUI_BINARY=$binary" >> $env:GITHUB_ENV
"FLYSSH_GUI_TEST_ARTIFACTS=$env:GITHUB_WORKSPACE/validation" >> $env:GITHUB_ENV
& $binary --version
if ($LASTEXITCODE -ne 0) { throw 'Packaged executable failed version smoke test' }
Get-FileHash dist/*.zip -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLowerInvariant()) $([IO.Path]::GetFileName($_.Path))" } | Set-Content dist/checksums.txt

- name: Test the packaged executable over real SSH and native Windows controls
shell: pwsh
run: |
go test ./e2e -run '^TestWindowsGUIHashes$' -count=1 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

- name: Upload binaries and validation evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: flyssh-windows-${{ github.sha }}
path: |
dist/*.zip
dist/checksums.txt
validation/*.log
validation/*.txt
validation/*.png
if-no-files-found: error
retention-days: 14
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog / 更新日志

## v2.0.15 (2026-09-17)

### Features / 新功能

- Add **Hash** between **+Dir** and **MV** in both Windows transfer panes. Select one or more files and choose MD5, SHA-1, SHA-224, SHA-256 (default), SHA-384 or SHA-512. Every result and filename is printed in the Log and terminal / Windows 文件传输窗口两侧新增 Hash 按钮,支持多文件及六种哈希算法,结果显示在日志和终端中。
- Stream local files with bounded memory and hash remote files on the server over the existing SSH route. Report per-file failures without discarding other results; disable hashing for folders and while busy / 本地流式计算,远程在服务器端计算;逐文件报告错误,不影响其余结果。
- Drain subprocess output before waiting so the last checksum is not truncated / 修复子进程输出末尾可能被截断的问题。

### Verification / 验证

- Portable checksum vectors, cancellation, quoting, fallback, failure and batching tests.
- Native Windows selection and subprocess-output tests, plus packaged-executable GUI tests against a loopback SSH fixture for all six methods in both panes.
- Release requests wait for Linux and Windows CI, publish through the existing release pipeline, then download, checksum-verify and execute the published Linux and Windows amd64 binaries.

---

## v2.0.14 (2026-09-04)

### Fixes / 修复
Expand Down
54 changes: 54 additions & 0 deletions docs/file-hashes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# File hashes in the Windows transfer GUI

Both the Local and Remote toolbars have **+Dir | Hash | MV | Del**.
Select one or more files in one pane, click **Hash**, choose an algorithm, and
click **Calculate**. The default is SHA-256. MD5, SHA-1, SHA-224, SHA-384 and
SHA-512 are also available. Cancel closes the chooser without reading any files.

Hash is disabled for an empty selection, folders, mixed file/folder selections,
a pane without a current directory, and while another operation is running.
Hashing does not recurse into folders. The selected paths are captured before
the chooser opens, so later selection or navigation changes cannot redirect the
operation. Hashing runs on a worker goroutine, leaving the window responsive.

Each successful result appears in the **Log** and the terminal, as
`checksum filename`. Filenames containing backslashes, carriage returns or
newlines use the GNU checksum escaped-filename convention (a leading backslash
before the checksum, and escaped characters in the filename). Errors are
reported for each failed file; the remaining selected files are still processed.
A partial or failed read is never presented as a successful checksum. Closing
the window cancels local hashing and terminates the remote child process.

Local files are streamed through Go's hash implementations with bounded memory;
no external Windows checksum program is required. Remote files are read and
hashed **on the remote host**, over the same FlySSH route and authentication as
other remote operations. File contents are not downloaded. Remote hashing uses
an available `*sum` utility, `shasum` for SHA algorithms, a BSD hash utility, or
OpenSSL. A missing or incompatible utility produces a visible error. Long
selections are batched to stay within the Windows child-command size limit.

Only regular files (including links to regular files) are hashed. A file that
changes during hashing has no snapshot guarantee. MD5 and SHA-1 are provided for
compatibility with existing checksums, not for authentication.

## Verification

Linux CI runs portable tests for all six algorithms, binary streaming,
cancellation, shell quoting, unusual filenames, command batching, utility
fallbacks and per-file failures. Windows GUI CI runs Windows selection and
subprocess tests, builds both Windows architectures in release mode, extracts
the amd64 ZIP and drives the actual executable's native controls against a
loopback SSH fixture. It checks button placement and enablement, chooser default
and cancellation, all six methods in both panes, and partial failures.

To test a downloaded Windows release with Git Bash installed:

```powershell
$env:FLYSSH_GUI_BINARY = 'C:\path\to\flyssh.exe'
$env:FLYSSH_GUI_TEST_ARTIFACTS = 'C:\path\to\validation'
go test ./e2e -run '^TestWindowsGUIHashes$' -count=1 -v -timeout 5m
```

Tests use temporary directories and fixture-only SSH credentials. They never
access a personal SSH server or personal files. Windows arm64 is cross-built;
runtime GUI verification runs on Windows amd64.
Loading
Loading