From f7ceb6c621a98ab0b8cab8980255d4a98e680bd4 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 14:38:08 +0100 Subject: [PATCH 01/25] test checksum failures --- .github/fixtures/checksum/SHA256SUMS | 1 + .github/fixtures/checksum/sotto-fixture | 1 + .github/workflows/test.yml | 49 +++++++++++++++++++++++++ scripts/install.ps1 | 17 ++------- scripts/install.sh | 14 +------ scripts/verify-checksum.ps1 | 30 +++++++++++++++ scripts/verify-checksum.sh | 26 +++++++++++++ 7 files changed, 112 insertions(+), 26 deletions(-) create mode 100644 .github/fixtures/checksum/SHA256SUMS create mode 100644 .github/fixtures/checksum/sotto-fixture create mode 100644 scripts/verify-checksum.ps1 create mode 100755 scripts/verify-checksum.sh diff --git a/.github/fixtures/checksum/SHA256SUMS b/.github/fixtures/checksum/SHA256SUMS new file mode 100644 index 0000000..2b97b13 --- /dev/null +++ b/.github/fixtures/checksum/SHA256SUMS @@ -0,0 +1 @@ +e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture diff --git a/.github/fixtures/checksum/sotto-fixture b/.github/fixtures/checksum/sotto-fixture new file mode 100644 index 0000000..ee8c1ee --- /dev/null +++ b/.github/fixtures/checksum/sotto-fixture @@ -0,0 +1 @@ +fixture diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 964bb4e..e8fa014 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -202,6 +202,55 @@ jobs: } } + reject-tampered-checksum: + name: reject-tampered-checksum (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - name: Check Linux checksum error + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + fixture="$RUNNER_TEMP/sotto-fixture" + cp .github/fixtures/checksum/sotto-fixture "$fixture" + printf 'tampered\n' >> "$fixture" + + set +e + output="$(scripts/verify-checksum.sh \ + "$fixture" .github/fixtures/checksum/SHA256SUMS 2>&1)" + status=$? + set -e + + test "$status" -ne 0 + test "$output" = 'error: checksum verification failed for sotto-fixture' + + - name: Check Windows checksum error + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $Fixture = Join-Path $env:RUNNER_TEMP "sotto-fixture" + Copy-Item .github/fixtures/checksum/sotto-fixture $Fixture + Add-Content -NoNewline -Path $Fixture -Value "tampered" + $Expected = "error: checksum verification failed for sotto-fixture" + + try { + & scripts/verify-checksum.ps1 ` + -AssetPath $Fixture ` + -SumsPath .github/fixtures/checksum/SHA256SUMS + throw "checksum helper accepted a tampered fixture" + } catch { + if ($_.Exception.Message -ne $Expected) { + throw "unexpected checksum error: $($_.Exception.Message)" + } + } + install-and-verify: strategy: fail-fast: false diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f55e235..104f1c2 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -90,20 +90,9 @@ try { } Write-Host "signatures verified" - $ExpectedHashes = @() - foreach ($Line in Get-Content -Path $SumsPath) { - if ($Line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2] -eq $Asset) { - $ExpectedHashes += $Matches[1] - } - } - if ($ExpectedHashes.Count -ne 1) { - Fail "$Asset must appear exactly once in SHA256SUMS" - } - - $ActualHash = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash - if ($ActualHash -ne $ExpectedHashes[0]) { - Fail "checksum verification failed for $Asset" - } + & (Join-Path $PSScriptRoot "verify-checksum.ps1") ` + -AssetPath $ArchivePath ` + -SumsPath $SumsPath Write-Host "checksum verified" Expand-Archive -Path $ArchivePath -DestinationPath $Tmp -Force diff --git a/scripts/install.sh b/scripts/install.sh index e08da15..ba5abe8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,6 +3,7 @@ set -eu REPO="getsotto/sotto" +script_dir="$(CDPATH= cd "$(dirname "$0")" && pwd)" say() { printf '%s\n' "$*" >&2 @@ -91,18 +92,7 @@ verify "SHA256SUMS" verify "$asset" say "signatures verified" -awk -v asset="$asset" '$2 == asset { print; found++ } END { exit found == 1 ? 0 : 1 }' \ - "$tmp/SHA256SUMS" >"$tmp/asset.sum" || - fail "$asset must appear exactly once in SHA256SUMS" - -( - cd "$tmp" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -c asset.sum >/dev/null - else - shasum -a 256 -c asset.sum >/dev/null - fi -) || fail "checksum verification failed for $asset" +"$script_dir/verify-checksum.sh" "$tmp/$asset" "$tmp/SHA256SUMS" say "checksum verified" stage="sotto-$version-$target" diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 new file mode 100644 index 0000000..a300f4a --- /dev/null +++ b/scripts/verify-checksum.ps1 @@ -0,0 +1,30 @@ +#Requires -Version 5.1 + +param( + [Parameter(Mandatory = $true)] + [string] $AssetPath, + [Parameter(Mandatory = $true)] + [string] $SumsPath +) + +$ErrorActionPreference = "Stop" + +function Fail([string] $Message) { + throw "error: $Message" +} + +$Asset = Split-Path -Leaf $AssetPath +$ExpectedHashes = @() +foreach ($Line in Get-Content -Path $SumsPath) { + if ($Line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2] -eq $Asset) { + $ExpectedHashes += $Matches[1] + } +} +if ($ExpectedHashes.Count -ne 1) { + Fail "$Asset must appear exactly once in SHA256SUMS" +} + +$ActualHash = (Get-FileHash -Algorithm SHA256 -Path $AssetPath).Hash +if ($ActualHash -ne $ExpectedHashes[0]) { + Fail "checksum verification failed for $Asset" +} diff --git a/scripts/verify-checksum.sh b/scripts/verify-checksum.sh new file mode 100755 index 0000000..f3d260d --- /dev/null +++ b/scripts/verify-checksum.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +set -eu + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +asset_path="$1" +sums_path="$2" +asset="${asset_path##*/}" + +expected="$({ + awk -v asset="$asset" \ + '$2 == asset { hash = tolower($1); found++ } END { if (found == 1) print hash; else exit 1 }' \ + "$sums_path" +})" || fail "$asset must appear exactly once in SHA256SUMS" + +if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$asset_path" | awk '{ print tolower($1) }')" +else + actual="$(shasum -a 256 "$asset_path" | awk '{ print tolower($1) }')" +fi + +[ "$actual" = "$expected" ] || fail "checksum verification failed for $asset" From c5c006b658fe8652b02522340c41564abea32e05 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 14:40:16 +0100 Subject: [PATCH 02/25] add release workflow --- .github/workflows/release.yml | 147 ++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 2 + 2 files changed, 149 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b94160c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,147 @@ +# Publishes an immutable action release, then moves the matching major convenience tag only after +# the full action test matrix succeeds. Dispatch this workflow from main with the exact release tag. + +name: release + +on: + workflow_dispatch: + inputs: + version: + description: Exact v1 action release tag + required: true + default: v1.1.0 + type: string + +# Called tests and validation only need to read the checkout. The release job receives its narrower +# write grant below, so no other job can create or move tags. +permissions: + contents: read + +# All action releases share one queue because each successful run may move the same v1 tag. A newer +# dispatch must not cancel an older run after it has created the immutable release but before moving v1. +concurrency: + group: action-release + +jobs: + validate: + name: Validate release + runs-on: ubuntu-latest + steps: + # Full history and tags are required for the ancestry and forward-version guards below. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Check release tag and commit + shell: bash + env: + RELEASE_TAG: ${{ inputs.version }} + run: | + set -euo pipefail + + if [[ ! "$RELEASE_TAG" =~ ^v1\.[0-9]+\.[0-9]+$ ]]; then + echo "error: version must be an exact v1 action release such as v1.1.0" >&2 + exit 1 + fi + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "error: action releases must be dispatched from main" >&2 + exit 1 + fi + + # A rerun may reuse an exact tag only when it already names this workflow commit. + if git rev-parse --verify --quiet "$RELEASE_TAG^{commit}" >/dev/null; then + if [ "$(git rev-parse "$RELEASE_TAG^{commit}")" != "$GITHUB_SHA" ]; then + echo "error: $RELEASE_TAG already points to another commit" >&2 + exit 1 + fi + fi + + latest="$(git tag --list 'v1.*.*' | \ + grep -E '^v1\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" + if [ -n "$latest" ]; then + newest="$(printf '%s\n%s\n' "$latest" "$RELEASE_TAG" | sort -V | tail -n 1)" + if [ "$newest" != "$RELEASE_TAG" ]; then + echo "error: $RELEASE_TAG would move v1 backwards from $latest" >&2 + exit 1 + fi + fi + + # Commit ancestry is checked independently of version ordering so v1 can never move to + # an older main commit even if a larger version number is dispatched by mistake. + if git rev-parse --verify --quiet 'v1^{commit}' >/dev/null && \ + ! git merge-base --is-ancestor 'v1^{commit}' "$GITHUB_SHA"; then + echo "error: v1 does not point to an ancestor of $GITHUB_SHA" >&2 + exit 1 + fi + + test: + name: Test action + needs: validate + uses: ./.github/workflows/test.yml + + release: + name: Publish release + needs: [validate, test] + runs-on: ubuntu-latest + # This job creates the exact release and updates the v1 convenience tag. + permissions: + contents: write + steps: + # Full history preserves the same ancestry guarantees when the queued release job starts. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Create exact release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.version }} + run: | + set -euo pipefail + + # GitHub's bot identity keeps the annotated tag attributable without a maintainer key. + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + if git rev-parse --verify --quiet "$RELEASE_TAG^{commit}" >/dev/null; then + test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$GITHUB_SHA" + else + git tag -a "$RELEASE_TAG" "$GITHUB_SHA" -m "$RELEASE_TAG" + git push origin "refs/tags/$RELEASE_TAG" + fi + + # This guard makes reruns idempotent after the tag or release has already been created. + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" --verify-tag --generate-notes --latest + fi + + - name: Move v1 after release validation + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.version }} + run: | + set -euo pipefail + + # Recheck the release and ancestry immediately before the only destructive operation. + gh release view "$RELEASE_TAG" >/dev/null + if git rev-parse --verify --quiet 'v1^{commit}' >/dev/null && \ + ! git merge-base --is-ancestor 'v1^{commit}' "$GITHUB_SHA"; then + echo "error: refusing to move v1 backwards" >&2 + exit 1 + fi + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag -fa v1 "$GITHUB_SHA" -m "v1 tracks $RELEASE_TAG" + git push origin refs/tags/v1 --force + + # The repository already has a v1 GitHub Release. Keep its notes consistent with the + # moved tag while the exact release remains the canonical, latest release record. + if gh release view v1 >/dev/null 2>&1; then + gh release edit v1 \ + --title v1 \ + --notes "Tracks the latest validated v1 release: $RELEASE_TAG." \ + --latest=false + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8fa014..2901fba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,8 @@ on: push: branches: [main] pull_request: + # The release workflow reuses the identical matrix before it creates or moves any tag. + workflow_call: env: SOTTO_TEST_VERSION: v0.4.0 From 2655efde4835912b72786018b8eec60096838bc3 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 14:41:04 +0100 Subject: [PATCH 03/25] document action releases --- README.md | 5 +++++ RELEASING.md | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 RELEASING.md diff --git a/README.md b/README.md index 05b60a5..b4c2a80 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,8 @@ Give the step an `id` to use the resolved installation details in later steps: This action is tagged independently of the `sotto` CLI's own version (`v0.1.0`, `v0.2.0`, ...) - `sotto-action@v1` and `sotto-version: v0.4.0` are two unrelated version numbers. See [getsotto/sotto#67](https://github.com/getsotto/sotto/issues/67) for why. + +Use `getsotto/sotto-action@v1` to follow the latest validated v1 action release, or pin an exact +action release such as `getsotto/sotto-action@v1.1.0` for an immutable workflow dependency. The +`v1` convenience tag moves only after the exact release has passed the full test matrix and been +published. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..4290d42 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,27 @@ +# Releasing Sotto Setup + +Action releases use immutable `v1.x.y` tags. The moving `v1` tag is a convenience pointer for +users who want compatible updates without changing their workflow. + +## Cutting a release + +1. Merge the release changes to `main` and wait for its test workflow to pass. +2. Run the `release` workflow from `main` with the exact action version, for example `v1.1.0`. +3. Wait for its reused test matrix and release job to finish. +4. Confirm both tags resolve to the validated commit and the exact release is latest: + + ```sh + git fetch --tags origin + test "$(git rev-parse v1^{commit})" = "$(git rev-parse v1.1.0^{commit})" + gh release view v1.1.0 + ``` + +The workflow creates the exact annotated tag and GitHub Release first. It verifies that release +exists before force-moving `v1`, then updates the existing `v1` release notes to name the exact +release it tracks. All release runs share one concurrency queue, and both version ordering and git +ancestry are checked before the move, so overlapping or older dispatches cannot move `v1` +backwards. + +Rerunning the same version is safe when its exact tag already points to the workflow commit. Never +move an exact release tag or move `v1` manually. If a published release needs a fix, merge the fix +and release a higher patch version. From 08217b005e0d448af96d483d630206955675921e Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 14:42:08 +0100 Subject: [PATCH 04/25] explain checksum helpers --- scripts/install.sh | 1 + scripts/verify-checksum.ps1 | 1 + scripts/verify-checksum.sh | 1 + 3 files changed, 3 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index ba5abe8..b155827 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,6 +3,7 @@ set -eu REPO="getsotto/sotto" +# Resolve helpers beside this script because callers may run the installer from any directory. script_dir="$(CDPATH= cd "$(dirname "$0")" && pwd)" say() { diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 index a300f4a..97bf7c8 100644 --- a/scripts/verify-checksum.ps1 +++ b/scripts/verify-checksum.ps1 @@ -1,5 +1,6 @@ #Requires -Version 5.1 +# Kept separate so the installer and offline tampered fixtures exercise the same checksum path. param( [Parameter(Mandatory = $true)] [string] $AssetPath, diff --git a/scripts/verify-checksum.sh b/scripts/verify-checksum.sh index f3d260d..bb9178e 100755 --- a/scripts/verify-checksum.sh +++ b/scripts/verify-checksum.sh @@ -1,5 +1,6 @@ #!/bin/sh +# Kept separate so the installer and offline tampered fixtures exercise the same checksum path. set -eu fail() { From 9c51d19bc2ae3cdd2ce070dcda2cb892e18e81a7 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 14:43:59 +0100 Subject: [PATCH 05/25] explain release history check --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b94160c..8d7a638 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,7 @@ jobs: fi fi + # Before the first exact release, grep has no match; treat that as an empty history. latest="$(git tag --list 'v1.*.*' | \ grep -E '^v1\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" if [ -n "$latest" ]; then From 7ad5c5a6f3ac45d0110dc47a3bc21efd3545002d Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 23:09:02 +0100 Subject: [PATCH 06/25] refresh release tags --- .github/workflows/release.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d7a638..0c34dfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,13 @@ jobs: exit 1 fi + # Refresh remote tags explicitly so version and ancestry guards cannot use stale refs. + git fetch --force origin '+refs/tags/*:refs/tags/*' + if ! git rev-parse --verify --quiet 'v1^{commit}' >/dev/null; then + echo "error: remote v1 convenience tag is missing" >&2 + exit 1 + fi + # A rerun may reuse an exact tag only when it already names this workflow commit. if git rev-parse --verify --quiet "$RELEASE_TAG^{commit}" >/dev/null; then if [ "$(git rev-parse "$RELEASE_TAG^{commit}")" != "$GITHUB_SHA" ]; then @@ -101,6 +108,9 @@ jobs: run: | set -euo pipefail + # The queued job may start after another ref update, so refresh the refs before tagging. + git fetch --force origin '+refs/tags/*:refs/tags/*' + # GitHub's bot identity keeps the annotated tag attributable without a maintainer key. git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' @@ -127,8 +137,11 @@ jobs: # Recheck the release and ancestry immediately before the only destructive operation. gh release view "$RELEASE_TAG" >/dev/null - if git rev-parse --verify --quiet 'v1^{commit}' >/dev/null && \ - ! git merge-base --is-ancestor 'v1^{commit}' "$GITHUB_SHA"; then + if ! git rev-parse --verify --quiet 'v1^{commit}' >/dev/null; then + echo "error: remote v1 convenience tag is missing" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor 'v1^{commit}' "$GITHUB_SHA"; then echo "error: refusing to move v1 backwards" >&2 exit 1 fi From 890cd69fb34a24b7ab58597bf80bcda91294669b Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 23:09:12 +0100 Subject: [PATCH 07/25] clarify release queue --- .github/workflows/release.yml | 6 ++++-- RELEASING.md | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c34dfa..f9230a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,10 +17,12 @@ on: permissions: contents: read -# All action releases share one queue because each successful run may move the same v1 tag. A newer -# dispatch must not cancel an older run after it has created the immutable release but before moving v1. +# All action releases share one queue because each successful run may move the same v1 tag. GitHub +# keeps one active and one pending run; a newer pending dispatch may replace an older pending run, +# but it must never cancel an active run after that run has created the immutable release. concurrency: group: action-release + cancel-in-progress: false jobs: validate: diff --git a/RELEASING.md b/RELEASING.md index 4290d42..dd169c6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -18,9 +18,9 @@ users who want compatible updates without changing their workflow. The workflow creates the exact annotated tag and GitHub Release first. It verifies that release exists before force-moving `v1`, then updates the existing `v1` release notes to name the exact -release it tracks. All release runs share one concurrency queue, and both version ordering and git -ancestry are checked before the move, so overlapping or older dispatches cannot move `v1` -backwards. +release it tracks. All release runs share one concurrency queue. GitHub keeps one active and one +pending run, so a newer pending dispatch can replace an older pending dispatch; version ordering +and git ancestry are checked before any run moves `v1`. Rerunning the same version is safe when its exact tag already points to the workflow commit. Never move an exact release tag or move `v1` manually. If a published release needs a fix, merge the fix From 1943ceeaffcb62b331fec251c36b8623515f544b Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 23:09:43 +0100 Subject: [PATCH 08/25] test checksum manifests --- .github/workflows/test.yml | 52 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2901fba..4c55e84 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -214,7 +214,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Check Linux checksum error + - name: Check Linux checksum errors if: runner.os != 'Windows' shell: bash run: | @@ -232,7 +232,30 @@ jobs: test "$status" -ne 0 test "$output" = 'error: checksum verification failed for sotto-fixture' - - name: Check Windows checksum error + # Exercise the manifest cardinality guard separately from the digest mismatch above. + sums="$RUNNER_TEMP/SHA256SUMS" + printf '%s\n' \ + 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f other-file' \ + > "$sums" + set +e + output="$(scripts/verify-checksum.sh "$fixture" "$sums" 2>&1)" + status=$? + set -e + test "$status" -ne 0 + test "$output" = 'error: sotto-fixture must appear exactly once in SHA256SUMS' + + printf '%s\n%s\n' \ + 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture' \ + 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture' \ + > "$sums" + set +e + output="$(scripts/verify-checksum.sh "$fixture" "$sums" 2>&1)" + status=$? + set -e + test "$status" -ne 0 + test "$output" = 'error: sotto-fixture must appear exactly once in SHA256SUMS' + + - name: Check Windows checksum errors if: runner.os == 'Windows' shell: pwsh run: | @@ -253,6 +276,31 @@ jobs: } } + $Manifest = Join-Path $env:RUNNER_TEMP "SHA256SUMS" + Set-Content -Path $Manifest -Value "e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f other-file" + $ExpectedManifest = "error: sotto-fixture must appear exactly once in SHA256SUMS" + try { + & scripts/verify-checksum.ps1 -AssetPath $Fixture -SumsPath $Manifest + throw "checksum helper accepted a missing fixture entry" + } catch { + if ($_.Exception.Message -ne $ExpectedManifest) { + throw "unexpected manifest error: $($_.Exception.Message)" + } + } + + Set-Content -Path $Manifest -Value @( + "e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture" + "e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture" + ) + try { + & scripts/verify-checksum.ps1 -AssetPath $Fixture -SumsPath $Manifest + throw "checksum helper accepted duplicate fixture entries" + } catch { + if ($_.Exception.Message -ne $ExpectedManifest) { + throw "unexpected duplicate manifest error: $($_.Exception.Message)" + } + } + install-and-verify: strategy: fail-fast: false From 13188153f52536c4e628f6bac014cc18c8395562 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Fri, 31 Jul 2026 23:09:54 +0100 Subject: [PATCH 09/25] clarify action tags --- README.md | 4 +++- RELEASING.md | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b4c2a80..9928e56 100644 --- a/README.md +++ b/README.md @@ -59,4 +59,6 @@ This action is tagged independently of the `sotto` CLI's own version (`v0.1.0`, Use `getsotto/sotto-action@v1` to follow the latest validated v1 action release, or pin an exact action release such as `getsotto/sotto-action@v1.1.0` for an immutable workflow dependency. The `v1` convenience tag moves only after the exact release has passed the full test matrix and been -published. +published by the release workflow. `v1.1.0` is the first numbered v1 action release; the initial +bootstrap release used `v1` directly, so there is no `v1.0.0` tag. Manually pushing tags bypasses +the workflow and is not a supported release path. diff --git a/RELEASING.md b/RELEASING.md index dd169c6..48d069a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,9 @@ # Releasing Sotto Setup -Action releases use immutable `v1.x.y` tags. The moving `v1` tag is a convenience pointer for -users who want compatible updates without changing their workflow. +Action releases use exact `v1.x.y` tags that the release workflow treats as immutable. The moving +`v1` tag is a convenience pointer for users who want compatible updates without changing their +workflow. The initial bootstrap release used `v1` directly; `v1.1.0` is the first numbered release +and there is no `v1.0.0` tag. ## Cutting a release From 2d507e8d31aca068903638fbdba163a72a8d294a Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 22:35:34 +0100 Subject: [PATCH 10/25] test macOS checksums --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c55e84..011801c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -209,12 +209,12 @@ jobs: strategy: fail-fast: false matrix: - runner: [ubuntu-latest, windows-latest] + runner: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - - name: Check Linux checksum errors + - name: Check Linux/macOS checksum errors if: runner.os != 'Windows' shell: bash run: | From 1188ac27f51924dfe1861df4e79303d8dbb4d856 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 22:53:06 +0100 Subject: [PATCH 11/25] tighten checksum parsing --- .github/workflows/test.yml | 62 +++++++++++++++++++++++++++++++++++++ scripts/verify-checksum.ps1 | 2 +- scripts/verify-checksum.sh | 2 +- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 011801c..a3aeb12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -223,6 +223,22 @@ jobs: cp .github/fixtures/checksum/sotto-fixture "$fixture" printf 'tampered\n' >> "$fixture" + if [ "$RUNNER_OS" = "macOS" ]; then + # macOS also ships sha256sum; restrict PATH so this leg proves the shasum fallback. + checksum_bin="$RUNNER_TEMP/checksum-bin" + mkdir -p "$checksum_bin" + ln -s "$(command -v shasum)" "$checksum_bin/shasum" + ln -s "$(command -v awk)" "$checksum_bin/awk" + export PATH="$checksum_bin" + fi + + valid_fixture="$RUNNER_TEMP/sotto-fixture-valid" + cp .github/fixtures/checksum/sotto-fixture "$valid_fixture" + printf '%s\n' \ + 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f *sotto-fixture-valid' \ + > "$RUNNER_TEMP/SHA256SUMS-binary" + PATH="$PATH" scripts/verify-checksum.sh "$valid_fixture" "$RUNNER_TEMP/SHA256SUMS-binary" + set +e output="$(scripts/verify-checksum.sh \ "$fixture" .github/fixtures/checksum/SHA256SUMS 2>&1)" @@ -244,6 +260,26 @@ jobs: test "$status" -ne 0 test "$output" = 'error: sotto-fixture must appear exactly once in SHA256SUMS' + printf '%s\n' \ + 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture extrajunk' \ + > "$sums" + set +e + output="$(scripts/verify-checksum.sh "$fixture" "$sums" 2>&1)" + status=$? + set -e + test "$status" -ne 0 + test "$output" = 'error: sotto-fixture must appear exactly once in SHA256SUMS' + + printf '%s\n' \ + 'not-a-valid-sha256 sotto-fixture' \ + > "$sums" + set +e + output="$(scripts/verify-checksum.sh "$fixture" "$sums" 2>&1)" + status=$? + set -e + test "$status" -ne 0 + test "$output" = 'error: sotto-fixture must appear exactly once in SHA256SUMS' + printf '%s\n%s\n' \ 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture' \ 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture' \ @@ -265,6 +301,12 @@ jobs: Add-Content -NoNewline -Path $Fixture -Value "tampered" $Expected = "error: checksum verification failed for sotto-fixture" + $ValidFixture = Join-Path $env:RUNNER_TEMP "sotto-fixture-valid" + Copy-Item .github/fixtures/checksum/sotto-fixture $ValidFixture + $BinaryManifest = Join-Path $env:RUNNER_TEMP "SHA256SUMS-binary" + Set-Content -Path $BinaryManifest -Value "e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f *sotto-fixture-valid" + & scripts/verify-checksum.ps1 -AssetPath $ValidFixture -SumsPath $BinaryManifest + try { & scripts/verify-checksum.ps1 ` -AssetPath $Fixture ` @@ -301,6 +343,26 @@ jobs: } } + Set-Content -Path $Manifest -Value "e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture extrajunk" + try { + & scripts/verify-checksum.ps1 -AssetPath $Fixture -SumsPath $Manifest + throw "checksum helper accepted a trailing-junk entry" + } catch { + if ($_.Exception.Message -ne $ExpectedManifest) { + throw "unexpected trailing-junk error: $($_.Exception.Message)" + } + } + + Set-Content -Path $Manifest -Value "not-a-valid-sha256 sotto-fixture" + try { + & scripts/verify-checksum.ps1 -AssetPath $Fixture -SumsPath $Manifest + throw "checksum helper accepted a malformed hash" + } catch { + if ($_.Exception.Message -ne $ExpectedManifest) { + throw "unexpected malformed-hash error: $($_.Exception.Message)" + } + } + install-and-verify: strategy: fail-fast: false diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 index 97bf7c8..cbfe5be 100644 --- a/scripts/verify-checksum.ps1 +++ b/scripts/verify-checksum.ps1 @@ -17,7 +17,7 @@ function Fail([string] $Message) { $Asset = Split-Path -Leaf $AssetPath $ExpectedHashes = @() foreach ($Line in Get-Content -Path $SumsPath) { - if ($Line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2] -eq $Asset) { + if ($Line -match '^([0-9a-fA-F]{64})\s+(\*?)(.+)$' -and $Matches[3] -eq $Asset) { $ExpectedHashes += $Matches[1] } } diff --git a/scripts/verify-checksum.sh b/scripts/verify-checksum.sh index bb9178e..3d1f075 100755 --- a/scripts/verify-checksum.sh +++ b/scripts/verify-checksum.sh @@ -14,7 +14,7 @@ asset="${asset_path##*/}" expected="$({ awk -v asset="$asset" \ - '$2 == asset { hash = tolower($1); found++ } END { if (found == 1) print hash; else exit 1 }' \ + 'NF == 2 && length($1) == 64 && $1 ~ /^[0-9a-fA-F]+$/ && ($2 == asset || $2 == "*" asset) { hash = tolower($1); found++ } END { if (found == 1) print hash; else exit 1 }' \ "$sums_path" })" || fail "$asset must appear exactly once in SHA256SUMS" From c78b2267234a99fc811eeabf4bbe4eb6d775b7d8 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 22:54:36 +0100 Subject: [PATCH 12/25] fix checksum test harness --- .github/workflows/test.yml | 11 ++++++----- scripts/verify-checksum.ps1 | 10 ++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a3aeb12..382f3a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -223,6 +223,12 @@ jobs: cp .github/fixtures/checksum/sotto-fixture "$fixture" printf 'tampered\n' >> "$fixture" + valid_fixture="$RUNNER_TEMP/sotto-fixture-valid" + cp .github/fixtures/checksum/sotto-fixture "$valid_fixture" + printf '%s\n' \ + 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f *sotto-fixture-valid' \ + > "$RUNNER_TEMP/SHA256SUMS-binary" + if [ "$RUNNER_OS" = "macOS" ]; then # macOS also ships sha256sum; restrict PATH so this leg proves the shasum fallback. checksum_bin="$RUNNER_TEMP/checksum-bin" @@ -232,11 +238,6 @@ jobs: export PATH="$checksum_bin" fi - valid_fixture="$RUNNER_TEMP/sotto-fixture-valid" - cp .github/fixtures/checksum/sotto-fixture "$valid_fixture" - printf '%s\n' \ - 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f *sotto-fixture-valid' \ - > "$RUNNER_TEMP/SHA256SUMS-binary" PATH="$PATH" scripts/verify-checksum.sh "$valid_fixture" "$RUNNER_TEMP/SHA256SUMS-binary" set +e diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 index cbfe5be..debf8dc 100644 --- a/scripts/verify-checksum.ps1 +++ b/scripts/verify-checksum.ps1 @@ -17,8 +17,14 @@ function Fail([string] $Message) { $Asset = Split-Path -Leaf $AssetPath $ExpectedHashes = @() foreach ($Line in Get-Content -Path $SumsPath) { - if ($Line -match '^([0-9a-fA-F]{64})\s+(\*?)(.+)$' -and $Matches[3] -eq $Asset) { - $ExpectedHashes += $Matches[1] + if ($Line -match '^([0-9a-fA-F]{64})\s+(\*?.+)$') { + $EntryName = $Matches[2] + if ($EntryName.StartsWith('*')) { + $EntryName = $EntryName.Substring(1) + } + if ($EntryName -eq $Asset) { + $ExpectedHashes += $Matches[1] + } } } if ($ExpectedHashes.Count -ne 1) { From 2b44a7ce52b63b38abd2b1b85a8f9e76bafc8dfc Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 22:56:14 +0100 Subject: [PATCH 13/25] fix Windows checksum parsing --- scripts/verify-checksum.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 index debf8dc..0f7727c 100644 --- a/scripts/verify-checksum.ps1 +++ b/scripts/verify-checksum.ps1 @@ -17,13 +17,14 @@ function Fail([string] $Message) { $Asset = Split-Path -Leaf $AssetPath $ExpectedHashes = @() foreach ($Line in Get-Content -Path $SumsPath) { - if ($Line -match '^([0-9a-fA-F]{64})\s+(\*?.+)$') { - $EntryName = $Matches[2] + $Parts = $Line -split '\s+', 2 + if ($Parts.Count -eq 2 -and $Parts[0] -match '^[0-9a-fA-F]{64}$') { + $EntryName = $Parts[1] if ($EntryName.StartsWith('*')) { $EntryName = $EntryName.Substring(1) } if ($EntryName -eq $Asset) { - $ExpectedHashes += $Matches[1] + $ExpectedHashes += $Parts[0] } } } From 7bb22fb017d1aae7ab31ba00c6170df5c5673e05 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 22:58:16 +0100 Subject: [PATCH 14/25] fix Windows checksum fixture --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 382f3a0..1d658de 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -305,7 +305,8 @@ jobs: $ValidFixture = Join-Path $env:RUNNER_TEMP "sotto-fixture-valid" Copy-Item .github/fixtures/checksum/sotto-fixture $ValidFixture $BinaryManifest = Join-Path $env:RUNNER_TEMP "SHA256SUMS-binary" - Set-Content -Path $BinaryManifest -Value "e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f *sotto-fixture-valid" + $ValidHash = (Get-FileHash -Algorithm SHA256 -Path $ValidFixture).Hash + Set-Content -Path $BinaryManifest -Value "$ValidHash *sotto-fixture-valid" & scripts/verify-checksum.ps1 -AssetPath $ValidFixture -SumsPath $BinaryManifest try { From 04acc09ebed68ad0fa3ef4d80304e219bd1f3c53 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:15:06 +0100 Subject: [PATCH 15/25] clarify release major tag --- .github/workflows/release.yml | 61 +++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9230a4..0af249c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ -# Publishes an immutable action release, then moves the matching major convenience tag only after -# the full action test matrix succeeds. Dispatch this workflow from main with the exact release tag. +# Publishes an immutable `v1.x.y` action release, then moves the `v1` major convenience tag only +# after the full action test matrix succeeds. Dispatch this workflow from main with the exact tag. name: release @@ -7,8 +7,9 @@ on: workflow_dispatch: inputs: version: - description: Exact v1 action release tag + description: Exact action release tag (vMAJOR.MINOR.PATCH) required: true + # v1.1.0 is the first numbered release; the existing v1 tag remains the moving bootstrap tag. default: v1.1.0 type: string @@ -17,13 +18,17 @@ on: permissions: contents: read -# All action releases share one queue because each successful run may move the same v1 tag. GitHub +# All action releases share one queue because each successful run may move the same major tag. GitHub # keeps one active and one pending run; a newer pending dispatch may replace an older pending run, # but it must never cancel an active run after that run has created the immutable release. concurrency: group: action-release cancel-in-progress: false +# Keep the moving major tag separate from the exact release tag supplied at dispatch time. +env: + ACTION_MAJOR_TAG: v1 + jobs: validate: name: Validate release @@ -41,8 +46,8 @@ jobs: run: | set -euo pipefail - if [[ ! "$RELEASE_TAG" =~ ^v1\.[0-9]+\.[0-9]+$ ]]; then - echo "error: version must be an exact v1 action release such as v1.1.0" >&2 + if [[ ! "$RELEASE_TAG" =~ ^${ACTION_MAJOR_TAG}\.[0-9]+\.[0-9]+$ ]]; then + echo "error: version must be an exact ${ACTION_MAJOR_TAG} action release such as ${ACTION_MAJOR_TAG}.1.0" >&2 exit 1 fi if [ "$GITHUB_REF" != "refs/heads/main" ]; then @@ -52,8 +57,8 @@ jobs: # Refresh remote tags explicitly so version and ancestry guards cannot use stale refs. git fetch --force origin '+refs/tags/*:refs/tags/*' - if ! git rev-parse --verify --quiet 'v1^{commit}' >/dev/null; then - echo "error: remote v1 convenience tag is missing" >&2 + if ! git rev-parse --verify --quiet "${ACTION_MAJOR_TAG}^{commit}" >/dev/null; then + echo "error: remote ${ACTION_MAJOR_TAG} convenience tag is missing" >&2 exit 1 fi @@ -66,21 +71,21 @@ jobs: fi # Before the first exact release, grep has no match; treat that as an empty history. - latest="$(git tag --list 'v1.*.*' | \ - grep -E '^v1\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" + latest="$(git tag --list "${ACTION_MAJOR_TAG}.*.*" | \ + grep -E "^${ACTION_MAJOR_TAG}\.[0-9]+\.[0-9]+$" | sort -V | tail -n 1 || true)" if [ -n "$latest" ]; then newest="$(printf '%s\n%s\n' "$latest" "$RELEASE_TAG" | sort -V | tail -n 1)" if [ "$newest" != "$RELEASE_TAG" ]; then - echo "error: $RELEASE_TAG would move v1 backwards from $latest" >&2 + echo "error: $RELEASE_TAG would move $ACTION_MAJOR_TAG backwards from $latest" >&2 exit 1 fi fi - # Commit ancestry is checked independently of version ordering so v1 can never move to + # Commit ancestry is checked independently of version ordering so the major tag can never move to # an older main commit even if a larger version number is dispatched by mistake. - if git rev-parse --verify --quiet 'v1^{commit}' >/dev/null && \ - ! git merge-base --is-ancestor 'v1^{commit}' "$GITHUB_SHA"; then - echo "error: v1 does not point to an ancestor of $GITHUB_SHA" >&2 + if git rev-parse --verify --quiet "${ACTION_MAJOR_TAG}^{commit}" >/dev/null && \ + ! git merge-base --is-ancestor "${ACTION_MAJOR_TAG}^{commit}" "$GITHUB_SHA"; then + echo "error: ${ACTION_MAJOR_TAG} does not point to an ancestor of $GITHUB_SHA" >&2 exit 1 fi @@ -93,7 +98,7 @@ jobs: name: Publish release needs: [validate, test] runs-on: ubuntu-latest - # This job creates the exact release and updates the v1 convenience tag. + # This job creates the exact release and updates the major convenience tag. permissions: contents: write steps: @@ -129,7 +134,7 @@ jobs: gh release create "$RELEASE_TAG" --verify-tag --generate-notes --latest fi - - name: Move v1 after release validation + - name: Move major tag after release validation shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -139,25 +144,25 @@ jobs: # Recheck the release and ancestry immediately before the only destructive operation. gh release view "$RELEASE_TAG" >/dev/null - if ! git rev-parse --verify --quiet 'v1^{commit}' >/dev/null; then - echo "error: remote v1 convenience tag is missing" >&2 + if ! git rev-parse --verify --quiet "${ACTION_MAJOR_TAG}^{commit}" >/dev/null; then + echo "error: remote ${ACTION_MAJOR_TAG} convenience tag is missing" >&2 exit 1 fi - if ! git merge-base --is-ancestor 'v1^{commit}' "$GITHUB_SHA"; then - echo "error: refusing to move v1 backwards" >&2 + if ! git merge-base --is-ancestor "${ACTION_MAJOR_TAG}^{commit}" "$GITHUB_SHA"; then + echo "error: refusing to move ${ACTION_MAJOR_TAG} backwards" >&2 exit 1 fi git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git tag -fa v1 "$GITHUB_SHA" -m "v1 tracks $RELEASE_TAG" - git push origin refs/tags/v1 --force + git tag -fa "$ACTION_MAJOR_TAG" "$GITHUB_SHA" -m "$ACTION_MAJOR_TAG tracks $RELEASE_TAG" + git push origin "refs/tags/$ACTION_MAJOR_TAG" --force - # The repository already has a v1 GitHub Release. Keep its notes consistent with the + # The repository already has a major-tag GitHub Release. Keep its notes consistent with the # moved tag while the exact release remains the canonical, latest release record. - if gh release view v1 >/dev/null 2>&1; then - gh release edit v1 \ - --title v1 \ - --notes "Tracks the latest validated v1 release: $RELEASE_TAG." \ + if gh release view "$ACTION_MAJOR_TAG" >/dev/null 2>&1; then + gh release edit "$ACTION_MAJOR_TAG" \ + --title "$ACTION_MAJOR_TAG" \ + --notes "Tracks the latest validated $ACTION_MAJOR_TAG release: $RELEASE_TAG." \ --latest=false fi From bc264206ea2cd0dbd8bda6c8385898d12839dea9 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:15:19 +0100 Subject: [PATCH 16/25] pin checkout action --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1d658de..38cc6b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,7 @@ jobs: value: v0.4.0. runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Check Linux validation errors if: runner.os != 'Windows' @@ -149,7 +149,7 @@ jobs: runner: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # The archive 404s before signature verification. A fake cosign only satisfies the # installer's precondition, isolating the missing-release path from another download. @@ -212,7 +212,7 @@ jobs: runner: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Check Linux/macOS checksum errors if: runner.os != 'Windows' @@ -382,7 +382,7 @@ jobs: target: x86_64-pc-windows-msvc runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - id: sotto name: Install sotto uses: ./ From 17427c575f02a7b6902061fc4d9d72f5a39a8941 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:15:28 +0100 Subject: [PATCH 17/25] normalize checksum case --- .github/workflows/test.yml | 2 +- scripts/verify-checksum.ps1 | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38cc6b4..d43a9f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -305,7 +305,7 @@ jobs: $ValidFixture = Join-Path $env:RUNNER_TEMP "sotto-fixture-valid" Copy-Item .github/fixtures/checksum/sotto-fixture $ValidFixture $BinaryManifest = Join-Path $env:RUNNER_TEMP "SHA256SUMS-binary" - $ValidHash = (Get-FileHash -Algorithm SHA256 -Path $ValidFixture).Hash + $ValidHash = (Get-FileHash -Algorithm SHA256 -Path $ValidFixture).Hash.ToLowerInvariant() Set-Content -Path $BinaryManifest -Value "$ValidHash *sotto-fixture-valid" & scripts/verify-checksum.ps1 -AssetPath $ValidFixture -SumsPath $BinaryManifest diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 index 0f7727c..d0081d5 100644 --- a/scripts/verify-checksum.ps1 +++ b/scripts/verify-checksum.ps1 @@ -24,7 +24,7 @@ foreach ($Line in Get-Content -Path $SumsPath) { $EntryName = $EntryName.Substring(1) } if ($EntryName -eq $Asset) { - $ExpectedHashes += $Parts[0] + $ExpectedHashes += $Parts[0].ToLowerInvariant() } } } @@ -32,7 +32,7 @@ if ($ExpectedHashes.Count -ne 1) { Fail "$Asset must appear exactly once in SHA256SUMS" } -$ActualHash = (Get-FileHash -Algorithm SHA256 -Path $AssetPath).Hash +$ActualHash = (Get-FileHash -Algorithm SHA256 -Path $AssetPath).Hash.ToLowerInvariant() if ($ActualHash -ne $ExpectedHashes[0]) { Fail "checksum verification failed for $Asset" } From de653757def9397a1d5f40c09f33c0404b812156 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:15:34 +0100 Subject: [PATCH 18/25] explain CDPATH clearing --- scripts/install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/install.sh b/scripts/install.sh index b155827..1f39756 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,7 @@ set -eu REPO="getsotto/sotto" # Resolve helpers beside this script because callers may run the installer from any directory. +# Clear CDPATH so a caller's shell settings cannot print a directory into the captured path. script_dir="$(CDPATH= cd "$(dirname "$0")" && pwd)" say() { From 579b509d96b3ecc0f89525558854feb1211e2e14 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:30:05 +0100 Subject: [PATCH 19/25] refresh release tags --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0af249c..e9462c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,6 +143,7 @@ jobs: set -euo pipefail # Recheck the release and ancestry immediately before the only destructive operation. + git fetch --force origin '+refs/tags/*:refs/tags/*' gh release view "$RELEASE_TAG" >/dev/null if ! git rev-parse --verify --quiet "${ACTION_MAJOR_TAG}^{commit}" >/dev/null; then echo "error: remote ${ACTION_MAJOR_TAG} convenience tag is missing" >&2 From 0f72ed3ea96ba6577fe6e046e78e04287b526853 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:30:23 +0100 Subject: [PATCH 20/25] trim checksum manifest lines --- .github/workflows/test.yml | 4 ++-- scripts/verify-checksum.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d43a9f8..a63fb8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -226,7 +226,7 @@ jobs: valid_fixture="$RUNNER_TEMP/sotto-fixture-valid" cp .github/fixtures/checksum/sotto-fixture "$valid_fixture" printf '%s\n' \ - 'e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f *sotto-fixture-valid' \ + ' e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f *sotto-fixture-valid ' \ > "$RUNNER_TEMP/SHA256SUMS-binary" if [ "$RUNNER_OS" = "macOS" ]; then @@ -306,7 +306,7 @@ jobs: Copy-Item .github/fixtures/checksum/sotto-fixture $ValidFixture $BinaryManifest = Join-Path $env:RUNNER_TEMP "SHA256SUMS-binary" $ValidHash = (Get-FileHash -Algorithm SHA256 -Path $ValidFixture).Hash.ToLowerInvariant() - Set-Content -Path $BinaryManifest -Value "$ValidHash *sotto-fixture-valid" + Set-Content -Path $BinaryManifest -Value " $ValidHash *sotto-fixture-valid " & scripts/verify-checksum.ps1 -AssetPath $ValidFixture -SumsPath $BinaryManifest try { diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 index d0081d5..f64b637 100644 --- a/scripts/verify-checksum.ps1 +++ b/scripts/verify-checksum.ps1 @@ -17,9 +17,9 @@ function Fail([string] $Message) { $Asset = Split-Path -Leaf $AssetPath $ExpectedHashes = @() foreach ($Line in Get-Content -Path $SumsPath) { - $Parts = $Line -split '\s+', 2 + $Parts = $Line.Trim() -split '\s+', 2 if ($Parts.Count -eq 2 -and $Parts[0] -match '^[0-9a-fA-F]{64}$') { - $EntryName = $Parts[1] + $EntryName = $Parts[1].Trim() if ($EntryName.StartsWith('*')) { $EntryName = $EntryName.Substring(1) } From be6404e4a75297cc687acdbbb67e5c4243febfd5 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:37:09 +0100 Subject: [PATCH 21/25] align release job names --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9462c4..53cf9f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ env: jobs: validate: - name: Validate release + name: validate-release runs-on: ubuntu-latest steps: # Full history and tags are required for the ancestry and forward-version guards below. @@ -90,12 +90,12 @@ jobs: fi test: - name: Test action + name: test-action needs: validate uses: ./.github/workflows/test.yml release: - name: Publish release + name: publish-release needs: [validate, test] runs-on: ubuntu-latest # This job creates the exact release and updates the major convenience tag. From 378743ed12ecccc9c9b621b2322ce39a2db2a8c3 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:37:15 +0100 Subject: [PATCH 22/25] explain PowerShell helper path --- scripts/install.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 104f1c2..f9e35b6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -90,6 +90,7 @@ try { } Write-Host "signatures verified" + # Resolve the helper beside this script so callers may invoke the installer from any directory. & (Join-Path $PSScriptRoot "verify-checksum.ps1") ` -AssetPath $ArchivePath ` -SumsPath $SumsPath From c5552de86a44f7d82de5a1912f4325c798b75a14 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:37:31 +0100 Subject: [PATCH 23/25] explain checksum parsing --- scripts/verify-checksum.ps1 | 3 +++ scripts/verify-checksum.sh | 3 +++ 2 files changed, 6 insertions(+) diff --git a/scripts/verify-checksum.ps1 b/scripts/verify-checksum.ps1 index f64b637..ced1f69 100644 --- a/scripts/verify-checksum.ps1 +++ b/scripts/verify-checksum.ps1 @@ -17,9 +17,11 @@ function Fail([string] $Message) { $Asset = Split-Path -Leaf $AssetPath $ExpectedHashes = @() foreach ($Line in Get-Content -Path $SumsPath) { + # Require one 64-character hexadecimal digest and one filename so trailing fields are rejected. $Parts = $Line.Trim() -split '\s+', 2 if ($Parts.Count -eq 2 -and $Parts[0] -match '^[0-9a-fA-F]{64}$') { $EntryName = $Parts[1].Trim() + # Binary-mode checksum tools prefix the filename with '*'; text-mode entries do not. if ($EntryName.StartsWith('*')) { $EntryName = $EntryName.Substring(1) } @@ -32,6 +34,7 @@ if ($ExpectedHashes.Count -ne 1) { Fail "$Asset must appear exactly once in SHA256SUMS" } +# Get-FileHash returns uppercase while manifests commonly use lowercase; normalise both explicitly. $ActualHash = (Get-FileHash -Algorithm SHA256 -Path $AssetPath).Hash.ToLowerInvariant() if ($ActualHash -ne $ExpectedHashes[0]) { Fail "checksum verification failed for $Asset" diff --git a/scripts/verify-checksum.sh b/scripts/verify-checksum.sh index 3d1f075..6dd4cb9 100755 --- a/scripts/verify-checksum.sh +++ b/scripts/verify-checksum.sh @@ -12,12 +12,15 @@ asset_path="$1" sums_path="$2" asset="${asset_path##*/}" +# Require one 64-character hexadecimal digest and one filename so malformed or trailing fields +# cannot become a valid match. Binary-mode checksum tools prefix the filename with '*'. expected="$({ awk -v asset="$asset" \ 'NF == 2 && length($1) == 64 && $1 ~ /^[0-9a-fA-F]+$/ && ($2 == asset || $2 == "*" asset) { hash = tolower($1); found++ } END { if (found == 1) print hash; else exit 1 }' \ "$sums_path" })" || fail "$asset must appear exactly once in SHA256SUMS" +# Keep comparisons case-insensitive explicitly because checksum tools and manifests vary in case. if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$asset_path" | awk '{ print tolower($1) }')" else From a62f8d968ffd3bbbc8feb6773291aba124d6e861 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:37:42 +0100 Subject: [PATCH 24/25] explain Windows checksum tests --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a63fb8b..cd3b947 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -304,6 +304,7 @@ jobs: $ValidFixture = Join-Path $env:RUNNER_TEMP "sotto-fixture-valid" Copy-Item .github/fixtures/checksum/sotto-fixture $ValidFixture + # Exercise the binary marker and padded whitespace through the Windows helper. $BinaryManifest = Join-Path $env:RUNNER_TEMP "SHA256SUMS-binary" $ValidHash = (Get-FileHash -Algorithm SHA256 -Path $ValidFixture).Hash.ToLowerInvariant() Set-Content -Path $BinaryManifest -Value " $ValidHash *sotto-fixture-valid " @@ -320,6 +321,7 @@ jobs: } } + # Exercise the manifest cardinality guard separately from the digest mismatch above. $Manifest = Join-Path $env:RUNNER_TEMP "SHA256SUMS" Set-Content -Path $Manifest -Value "e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f other-file" $ExpectedManifest = "error: sotto-fixture must appear exactly once in SHA256SUMS" From 209aca4ad8be85268aaf722cd1acdf87d0616347 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sun, 2 Aug 2026 23:37:50 +0100 Subject: [PATCH 25/25] simplify ancestry guard --- .github/workflows/release.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53cf9f1..052fb85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,10 +81,8 @@ jobs: fi fi - # Commit ancestry is checked independently of version ordering so the major tag can never move to - # an older main commit even if a larger version number is dispatched by mistake. - if git rev-parse --verify --quiet "${ACTION_MAJOR_TAG}^{commit}" >/dev/null && \ - ! git merge-base --is-ancestor "${ACTION_MAJOR_TAG}^{commit}" "$GITHUB_SHA"; then + # The major tag exists from the guard above; check its ancestry independently of version ordering. + if ! git merge-base --is-ancestor "${ACTION_MAJOR_TAG}^{commit}" "$GITHUB_SHA"; then echo "error: ${ACTION_MAJOR_TAG} does not point to an ancestor of $GITHUB_SHA" >&2 exit 1 fi