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/fixtures/checksum/SHA256SUMS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
e80b71cd14d3cbd65f4173abcbfcf01a545dbca32a72d575108b553a648cc96f sotto-fixture
1 change: 1 addition & 0 deletions .github/fixtures/checksum/sotto-fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fixture
167 changes: 167 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
Maxerns marked this conversation as resolved.
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
171 changes: 168 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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: ./
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading