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/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..052fb85 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,167 @@ +# 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 + +on: + workflow_dispatch: + inputs: + version: + 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 + +# 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 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 + 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" =~ ^${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 + echo "error: action releases must be dispatched from main" >&2 + 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 "${ACTION_MAJOR_TAG}^{commit}" >/dev/null; then + echo "error: remote ${ACTION_MAJOR_TAG} 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 + echo "error: $RELEASE_TAG already points to another commit" >&2 + exit 1 + fi + fi + + # Before the first exact release, grep has no match; treat that as an empty history. + 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 $ACTION_MAJOR_TAG backwards from $latest" >&2 + exit 1 + fi + fi + + # 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 + + 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 major 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 + + # 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' + + 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 major tag 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. + 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 + exit 1 + fi + 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 "$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 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 "$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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 964bb4e..cd3b947 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 @@ -46,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' @@ -147,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. @@ -202,6 +204,169 @@ jobs: } } + reject-tampered-checksum: + name: reject-tampered-checksum (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Check Linux/macOS checksum errors + 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" + + 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" + 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 + + 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)" + status=$? + set -e + + test "$status" -ne 0 + test "$output" = 'error: checksum verification failed for sotto-fixture' + + # 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' \ + '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' \ + > "$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: | + $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" + + $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 " + & scripts/verify-checksum.ps1 -AssetPath $ValidFixture -SumsPath $BinaryManifest + + 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)" + } + } + + # 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" + 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)" + } + } + + 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 @@ -219,7 +384,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: ./ diff --git a/README.md b/README.md index 05b60a5..9928e56 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,10 @@ 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 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 new file mode 100644 index 0000000..48d069a --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,29 @@ +# Releasing Sotto Setup + +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 + +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. 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 +and release a higher patch version. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f55e235..f9e35b6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -90,20 +90,10 @@ 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" - } + # 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 Write-Host "checksum verified" Expand-Archive -Path $ArchivePath -DestinationPath $Tmp -Force diff --git a/scripts/install.sh b/scripts/install.sh index e08da15..1f39756 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,6 +3,9 @@ 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() { printf '%s\n' "$*" >&2 @@ -91,18 +94,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..ced1f69 --- /dev/null +++ b/scripts/verify-checksum.ps1 @@ -0,0 +1,41 @@ +#Requires -Version 5.1 + +# Kept separate so the installer and offline tampered fixtures exercise the same checksum path. +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) { + # 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) + } + if ($EntryName -eq $Asset) { + $ExpectedHashes += $Parts[0].ToLowerInvariant() + } + } +} +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 new file mode 100755 index 0000000..6dd4cb9 --- /dev/null +++ b/scripts/verify-checksum.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# Kept separate so the installer and offline tampered fixtures exercise the same checksum path. +set -eu + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +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 + actual="$(shasum -a 256 "$asset_path" | awk '{ print tolower($1) }')" +fi + +[ "$actual" = "$expected" ] || fail "checksum verification failed for $asset"