From 395bb51aaee3a6acdcc3109db7cac3620ddb09c9 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Fri, 2 Oct 2026 10:54:00 +0000 Subject: [PATCH 1/3] ci: align verification and library release workflows --- .github/workflows/main.yml | 50 ++---------- .github/workflows/release.yml | 78 +++++++++--------- .github/workflows/verify.yml | 98 ++++++++++++++++++++++ docs/release-checklist.md | 56 +++++++++++++ scripts/prepare-release-test.sh | 29 +++++++ scripts/prepare-release.sh | 23 +++++- scripts/release-candidate-test.py | 116 +++++++++++++++++++++++++++ scripts/release-consumer-imports.txt | 3 + scripts/verify-published-module.sh | 46 +++++++++++ scripts/verify-release-candidate.sh | 17 ++++ 10 files changed, 434 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 scripts/release-candidate-test.py create mode 100644 scripts/release-consumer-imports.txt create mode 100644 scripts/verify-published-module.sh create mode 100644 scripts/verify-release-candidate.sh diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 15bc734..5fc4c76 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,50 +1,14 @@ name: CI -permissions: - contents: read - on: push: - branches: [ 'master' ] + branches: [master] pull_request: + workflow_dispatch: -jobs: - test: - runs-on: ubuntu-24.04 - env: - GOTOOLCHAIN: local - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version: "1.26" - cache: true - - - name: Format check - run: | - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "gofmt needed for:" - echo "$unformatted" - exit 1 - fi - - - name: Vet - run: go vet ./... - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.13.2 - - - name: Run tests - run: go test -race -v ./... - - - name: Module tidy check - run: go mod tidy -diff +permissions: + contents: read - - name: Module verify - run: go mod verify +jobs: + verify: + uses: ./.github/workflows/verify.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d521e9b..d0e3fcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,6 @@ on: version: description: "Release version, for example v0.12.0" required: true - default: "v0.12.0" permissions: contents: read @@ -20,6 +19,9 @@ env: RELEASE_VERSION_INPUT: ${{ inputs.version }} jobs: + verify: + uses: ./.github/workflows/verify.yml + validate: name: Validate release runs-on: ubuntu-24.04 @@ -30,11 +32,6 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 - with: - go-version: "1.26" - cache: true - - name: Resolve release version id: version shell: bash @@ -73,7 +70,7 @@ jobs: - name: Validate release scripts run: | - bash -n scripts/*.sh + for script in scripts/*.sh; do bash -n "$script"; done bash scripts/prepare-release-test.sh - name: Check changelog readiness @@ -81,18 +78,12 @@ jobs: CHANGELOG_VERSION: ${{ steps.version.outputs.changelog_version }} run: bash scripts/prepare-release.sh "$CHANGELOG_VERSION" --check-only - - name: Lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.13.2 - - - name: Full release validation - run: bash scripts/test-all.sh - publish: name: Publish GitHub Release - needs: validate + needs: [validate, verify] runs-on: ubuntu-24.04 + outputs: + release_sha: ${{ steps.release_sha.outputs.sha }} permissions: contents: write steps: @@ -117,14 +108,12 @@ jobs: exit 1 fi - git checkout master - git pull --ff-only origin master + git checkout --detach "$DISPATCH_SHA" bash scripts/prepare-release.sh "$CHANGELOG_VERSION" if ! git diff --quiet -- CHANGELOG.md; then git add CHANGELOG.md git commit -m "chore(release): prepare CHANGELOG for ${RELEASE_VERSION}" - git push origin master fi - name: Resolve release commit SHA @@ -133,20 +122,8 @@ jobs: env: DISPATCH_SHA: ${{ needs.validate.outputs.dispatch_sha }} run: | + bash scripts/verify-release-candidate.sh "$DISPATCH_SHA" release_sha=$(git rev-parse HEAD) - if [[ "$release_sha" == "$DISPATCH_SHA" ]]; then - echo "changelog was already finalized on the validated commit" - elif [[ $(git rev-list --count "${DISPATCH_SHA}..HEAD") -eq 1 ]]; then - parent_sha=$(git rev-parse HEAD^) - changed_files=$(git diff-tree --no-commit-id --name-only -r HEAD) - if [[ "$parent_sha" != "$DISPATCH_SHA" || "$changed_files" != "CHANGELOG.md" ]]; then - echo "release commit must be a single changelog-only child of $DISPATCH_SHA" >&2 - exit 1 - fi - else - echo "unexpected commits between validated and release state" >&2 - exit 1 - fi echo "sha=$release_sha" >> "$GITHUB_OUTPUT" - name: Verify finalized changelog @@ -181,11 +158,17 @@ jobs: found && /^## \[/ { exit } found { print } ' CHANGELOG.md > release-notes.md - if [[ ! -s release-notes.md ]]; then - echo "api-testing ${{ needs.validate.outputs.version }}" > release-notes.md - echo >> release-notes.md - echo "See CHANGELOG.md for details." >> release-notes.md - fi + test -s release-notes.md + + - name: Push verified changelog commit + shell: bash + env: + DISPATCH_SHA: ${{ needs.validate.outputs.dispatch_sha }} + run: | + git fetch origin master + test "$(git rev-parse origin/master)" = "$DISPATCH_SHA" + bash scripts/verify-release-candidate.sh "$DISPATCH_SHA" + git push origin HEAD:refs/heads/master - name: Publish source-only GitHub Release uses: softprops/action-gh-release@v3 @@ -195,3 +178,24 @@ jobs: name: api-testing ${{ needs.validate.outputs.version }} body_path: release-notes.md prerelease: ${{ contains(needs.validate.outputs.version, '-') }} + + consumer: + name: Verify published module + needs: [validate, publish] + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + GOTOOLCHAIN: local + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.publish.outputs.release_sha }} + persist-credentials: false + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Resolve and compile exact published version + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: bash scripts/verify-published-module.sh "$RELEASE_VERSION" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..221304a --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,98 @@ +name: Shared verification + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + env: + GOTOOLCHAIN: local + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: "1.26.6" + cache: true + + - name: Format check + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "gofmt needed for:" + echo "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.13.2 + + - name: Run tests + run: go test -race -v ./... + + - name: Module tidy check + run: go mod tidy -diff + + - name: Module verify + run: go mod verify + + - name: Release script contracts + run: | + for script in scripts/*.sh; do bash -n "$script"; done + bash scripts/prepare-release-test.sh + python3 scripts/release-candidate-test.py + + minimum: + name: Minimum supported Go + runs-on: ubuntu-24.04 + env: + GOTOOLCHAIN: local + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Verify public module on supported minimum + run: | + go version + go build ./... + go test ./... + go test -race ./... + + vulnerability: + name: Vulnerabilities (${{ matrix.target }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + target: [minimum, tooling] + env: + GOTOOLCHAIN: local + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: '1.26.6' + - name: Install pinned scanner independently of consumer module + run: GOBIN="$RUNNER_TEMP/vuln-bin" go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + - uses: actions/setup-go@v7 + if: matrix.target == 'minimum' + with: + go-version-file: go.mod + - name: Scan with selected analysis toolchain + run: | + go version + "$RUNNER_TEMP/vuln-bin/govulncheck" -version + date -u + "$RUNNER_TEMP/vuln-bin/govulncheck" ./... diff --git a/docs/release-checklist.md b/docs/release-checklist.md index d2b69e4..fcaca24 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -78,3 +78,59 @@ a second local tag or move the published tag. - If a release or tag was published incorrectly, follow repository governance, document a retraction, and publish a corrective version. Do not rewrite public history or silently move a consumed tag. + +## Shared CI contract + +Normal CI and Release both call `.github/workflows/verify.yml` at the caller's +revision. Its required jobs cover the existing project gate, minimum Go from +`go.mod`, and pinned govulncheck v1.8.0 on both minimum and tooling Go. The scanner +is installed with tooling Go, then executed with the selected analysis toolchain. +No vulnerability failures are suppressed. Resolving findings or raising the support +floor is a separate compatibility decision; a newer clean scan cannot replace a +failed minimum lane. A two-component Go directive selects that release family's +available patch; a three-component directive pins the specified minimum patch. +Each job logs the effective toolchain. Tooling remains on the pinned Go 1.26.6 lane. + +Project-specific checks remain required, including Squirrel's database, nested +modules, differential/API and dependency gates where present. Modern tooling and +`go mod tidy -diff` do not run in the old minimum-Go compatibility lane. + +The publish job prepares from the immutable validated SHA and checks ancestry, +changed files, nonempty finalized notes and tag identity **before** pushing anything. +It rechecks the branch and uses a normal non-force push of that exact commit. +Branch movement rejects publication; no pull/rebase incorporates unverified work. +A subsequent unrelated branch commit does not change the already selected release +SHA. Repository release concurrency does not lock human pushes. + +After publication, the `consumer` job resolves the exact version in a temporary +external module, with workspace use disabled and an isolated module cache, and +compiles the public imports listed in `scripts/release-consumer-imports.txt`. +The cache is outside the consumer source directory and writable for cleanup. Download retries are bounded to accommodate proxy propagation; +a failed smoke check does not delete or move the published tag. Because bot pushes +need not trigger push workflows, this check is an explicit dependent job. + +## Recovery and rollout + +If the changelog commit was pushed but publication failed, inspect the tag/release +first and start a **fresh dispatch** on the current release branch with the same +version. Re-running the old event still has its old SHA and can fail the freshness +check. Existing same-commit tags may be reconciled; conflicting tags stop the flow. +The version input has no prefilled default to avoid reusing a previous release. + +Before merging this CI change, review branch protection required-check names: +reusable workflow jobs have new check names. Configure rules to require the new +quality, minimum and both vulnerability lanes; do not disable existing protections +without their replacements. Confirm the release bot can push its changelog commit. +No local validation can establish those hosted repository settings. + +Local release regression checks: + +```bash +bash scripts/prepare-release-test.sh +python3 scripts/release-candidate-test.py +``` + +The tests reject heading-only/planned/finalized empty notes, duplicate versions, +non-changelog or multiple child commits, merges and dirty tracked files; validation +modes do not rewrite the changelog. Hosted publication itself still needs a real +maintainer-authorized run after the workflow is merged. diff --git a/scripts/prepare-release-test.sh b/scripts/prepare-release-test.sh index a40fb29..caf388e 100755 --- a/scripts/prepare-release-test.sh +++ b/scripts/prepare-release-test.sh @@ -39,6 +39,34 @@ fixture() { echo "$test_root/${name}.md" } +test_content_contract() { + local file before + file=$(fixture content-contract) + printf '## [Unreleased]\n\n### Added\n\n' > "$file" + if run_prepare "$file" 9.9.9 --check-only >/dev/null 2>&1; then + fail "heading-only Unreleased rejected" + else pass "heading-only Unreleased rejected"; fi + printf '## [9.9.9] — planned\n\n### Fixed\n- \n' > "$file" + if run_prepare "$file" 9.9.9 --check-only >/dev/null 2>&1; then + fail "empty planned bullet rejected" + else pass "empty planned bullet rejected"; fi + printf '## [9.9.9] - 2026-01-01\n\n### Added\n' > "$file" + if run_prepare "$file" 9.9.9 --require-final >/dev/null 2>&1; then + fail "empty finalized section rejected" + else pass "empty finalized section rejected"; fi + printf '## [9.9.9] - 2026-01-01\n\n### Added\n- Working release\n' > "$file" + before=$(cat "$file") + if run_prepare "$file" 9.9.9 --check-only >/dev/null && + run_prepare "$file" 9.9.9 --require-final >/dev/null && + [[ "$before" == "$(cat "$file")" ]]; then + pass "validation modes are read-only" + else fail "validation modes are read-only"; fi + printf '\n## [9.9.9] — planned\n\n### Added\n- Duplicate\n' >> "$file" + if run_prepare "$file" 9.9.9 --check-only >/dev/null 2>&1; then + fail "duplicate version rejected" + else pass "duplicate version rejected"; fi +} + test_invalid_versions() { local file file=$(fixture invalid) @@ -223,6 +251,7 @@ test_invalid_date_fails() { fi } +test_content_contract test_invalid_versions test_planned_check_and_finalize test_finalize_is_idempotent diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh index dc7d457..42aaec6 100755 --- a/scripts/prepare-release.sh +++ b/scripts/prepare-release.sh @@ -214,13 +214,32 @@ section_has_content() { awk -v header="$header" ' $0 == header { in_section = 1; next } in_section && /^## \[/ { exit } - in_section && /^### / { found = 1; exit } + in_section { + line = $0 + gsub(//, "", line) + if (line ~ /^[[:space:]]*$/ || line ~ /^#/ || line ~ /^\[[^]]+\]:/ || line ~ /^[[:space:]]*[-*+][[:space:]]*$/) next + found = 1; exit + } END { exit(found ? 0 : 1) } ' "$changelog" } +# Duplicate version sections make extraction and idempotent reruns ambiguous. +section_count=$(awk -v prefix="## [${version}]" 'index($0, prefix) == 1 { n++ } END { print n+0 }' "$changelog") +if (( section_count > 1 )); then + echo "duplicate changelog sections for ${version}" >&2 + exit 1 +fi + if is_finalized; then - update_changelog_footer "$version" "$changelog" + final_header=$(awk -v prefix="$final_prefix" 'index($0, prefix) == 1 { print; exit }' "$changelog") + if ! section_has_content "$final_header"; then + echo "finalized changelog section is empty" >&2 + exit 1 + fi + if [[ "$check_only" != true && "$require_final" != true ]]; then + update_changelog_footer "$version" "$changelog" + fi echo "changelog section [${version}] is finalized" exit 0 fi diff --git a/scripts/release-candidate-test.py b/scripts/release-candidate-test.py new file mode 100644 index 0000000..0f779e7 --- /dev/null +++ b/scripts/release-candidate-test.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Exercise release ancestry checks in disposable Git repositories.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().with_name('verify-release-candidate.sh') + +class CandidateTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.git('init', '-q') + self.git('config', 'user.email', 'test@example.invalid') + self.git('config', 'user.name', 'Release test') + self.commit('CHANGELOG.md', 'base') + self.base = self.git('rev-parse', 'HEAD') + + def git(self, *args): + return subprocess.check_output(['git', *args], cwd=self.root, text=True, + stderr=subprocess.STDOUT).strip() + + def commit(self, path, text): + (self.root / path).write_text(text) + self.git('add', path) + self.git('commit', '-qm', text) + + def check(self, expected): + result = subprocess.run(['bash', str(SCRIPT), self.base], cwd=self.root, + capture_output=True, text=True) + self.assertEqual(result.returncode == 0, expected, result.stderr) + + def test_unchanged_candidate(self): + self.check(True) + + def test_one_changelog_child(self): + self.commit('CHANGELOG.md', 'release') + self.check(True) + + def test_code_child_rejected(self): + self.commit('source.go', 'code') + self.check(False) + + def test_two_children_rejected(self): + self.commit('CHANGELOG.md', 'release') + self.commit('CHANGELOG.md', 'another') + self.check(False) + + def test_dirty_tracked_file_rejected(self): + (self.root / 'CHANGELOG.md').write_text('uncommitted') + self.check(False) + + def test_merge_rejected(self): + self.git('checkout', '-qb', 'side') + self.commit('side.txt', 'side') + self.git('checkout', '--detach', self.base) + self.commit('CHANGELOG.md', 'release') + self.git('merge', '--no-ff', '-m', 'merge', 'side') + self.check(False) + + def test_annotated_candidate_resolves(self): + self.git('tag', '-a', 'candidate', '-m', 'candidate') + self.base = 'candidate' + self.check(True) + + +class PublicationTest(CandidateTest): + def setUp(self): + super().setUp() + workflow = SCRIPT.parent.parent / '.github/workflows/release.yml' + text = workflow.read_text() + block = text.split(' - name: Push verified changelog commit\n', 1)[1] + block = block.split(' - name:', 1)[0].split(' run: |\n', 1)[1] + self.command = '\n'.join(line[10:] for line in block.splitlines() if line.strip()) + self.branch = 'master' if 'origin/master' in self.command else 'main' + self.remote = self.root / 'remote.git' + self.git('init', '--bare', '-q', str(self.remote)) + self.git('remote', 'add', 'origin', str(self.remote)) + self.git('push', '-q', 'origin', 'HEAD:refs/heads/' + self.branch) + (self.root / 'scripts').mkdir() + (self.root / 'scripts/verify-release-candidate.sh').write_text(SCRIPT.read_text()) + + def publish(self): + env = os.environ.copy() + env['DISPATCH_SHA'] = self.base + return subprocess.run(['bash', '-euo', 'pipefail', '-c', self.command], + cwd=self.root, env=env, capture_output=True, text=True) + + def remote_head(self): + return self.git('--git-dir=' + str(self.remote), 'rev-parse', self.branch) + + def test_valid_child_pushes(self): + self.commit('CHANGELOG.md', 'release') + result = self.publish() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.remote_head(), self.git('rev-parse', 'HEAD')) + + def test_stale_branch_does_not_push(self): + self.commit('other.txt', 'remote work') + remote_head = self.git('rev-parse', 'HEAD') + self.git('push', '-q', 'origin', 'HEAD:refs/heads/' + self.branch) + self.git('checkout', '--detach', self.base) + self.commit('CHANGELOG.md', 'release') + self.assertNotEqual(self.publish().returncode, 0) + self.assertEqual(self.remote_head(), remote_head) + + def test_code_change_does_not_push(self): + self.commit('other.txt', 'unexpected code') + self.assertNotEqual(self.publish().returncode, 0) + self.assertEqual(self.remote_head(), self.base) + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/release-consumer-imports.txt b/scripts/release-consumer-imports.txt new file mode 100644 index 0000000..a41f189 --- /dev/null +++ b/scripts/release-consumer-imports.txt @@ -0,0 +1,3 @@ +github.com/muonsoft/api-testing/apitest +github.com/muonsoft/api-testing/assertjson +github.com/muonsoft/api-testing/assertxml diff --git a/scripts/verify-published-module.sh b/scripts/verify-published-module.sh new file mode 100644 index 0000000..839cfca --- /dev/null +++ b/scripts/verify-published-module.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Verify remote publication without workspace or local replacement leakage. +set -euo pipefail +version=${1:?release version required} +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +[[ "$version" == v* ]] || { echo 'version must have v prefix' >&2; exit 1; } +bash "$repo_root/scripts/prepare-release.sh" "${version#v}" --semver-only +module=$(awk '/^module / { print $2; exit }' "$repo_root/go.mod") +[[ -n "$module" ]] || exit 1 +scratch=$(mktemp -d) +trap 'rm -rf -- "$scratch"' EXIT +export GOWORK=off GOFLAGS=-modcacherw GOTOOLCHAIN=local +export GOMODCACHE="$scratch/modcache" +mkdir "$scratch/consumer" +cd "$scratch/consumer" +go mod init example.com/release-smoke +# Retry only fetching: public proxies may not observe the new tag immediately. +for attempt in 1 2 3 4 5; do + if go mod download "$module@$version"; then break; fi + if [[ "$attempt" == 5 ]]; then + echo 'published version did not resolve; inspect publication, never move the tag' >&2 + exit 1 + fi + sleep "$((attempt * 5))" +done +go mod edit "-require=$module@$version" +printf 'package main\n' > main.go +while IFS= read -r package; do + [[ "$package" == "$module" || "$package" == "$module/"* ]] || { + echo "consumer imports must belong to $module" >&2; exit 1; + } + printf 'import _ "%s"\n' "$package" >> main.go +done < "$repo_root/scripts/release-consumer-imports.txt" +printf 'func main() {}\n' >> main.go +go mod tidy +go list -m -json "$module" > resolved.json +python3 - "$module" "$version" <<'PY' +import json, sys +with open('resolved.json') as f: + resolved = json.load(f) +if resolved.get('Path') != sys.argv[1] or resolved.get('Version') != sys.argv[2] or 'Replace' in resolved: + raise SystemExit('consumer resolved an unexpected module/version or replacement') +PY +go build -buildvcs=false ./... +go version +printf 'Published consumer verified: %s@%s\n' "$module" "$version" diff --git a/scripts/verify-release-candidate.sh b/scripts/verify-release-candidate.sh new file mode 100644 index 0000000..df4fdcd --- /dev/null +++ b/scripts/verify-release-candidate.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Read-only check: HEAD is the validated commit or its single changelog-only child. +set -euo pipefail +candidate=${1:?validated commit required} +candidate=$(git rev-parse --verify "${candidate}^{commit}") +git diff --exit-code --quiet +git diff --cached --exit-code --quiet +head=$(git rev-parse HEAD) +if [[ "$head" == "$candidate" ]]; then + exit 0 +fi +parents=$(git show -s --format=%P HEAD) +changed=$(git diff-tree --no-commit-id --name-only -r HEAD) +if [[ "$parents" != "$candidate" || "$changed" != CHANGELOG.md ]]; then + echo "release HEAD must be the validated SHA or its single changelog-only child" >&2 + exit 1 +fi From 58540d705b93cd8b47c87c7e47bd77b8beabc594 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Fri, 2 Oct 2026 11:09:25 +0000 Subject: [PATCH 2/3] ci: separate required security from legacy Go diagnostics --- .github/workflows/legacy-security.yml | 39 ++++++++++++++++++++++ .github/workflows/main.yml | 2 ++ .github/workflows/release.yml | 2 ++ .github/workflows/verify.yml | 34 ++++++++++--------- docs/release-checklist.md | 36 ++++++++++++++------ scripts/security-report-test.py | 48 +++++++++++++++++++++++++++ scripts/security-report.sh | 47 ++++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/legacy-security.yml create mode 100644 scripts/security-report-test.py create mode 100644 scripts/security-report.sh diff --git a/.github/workflows/legacy-security.yml b/.github/workflows/legacy-security.yml new file mode 100644 index 0000000..73a65d3 --- /dev/null +++ b/.github/workflows/legacy-security.yml @@ -0,0 +1,39 @@ +name: Legacy Go security report + +on: + workflow_call: + +permissions: + contents: read + +jobs: + security: + name: Legacy Go security report + runs-on: ubuntu-24.04 + env: + GOTOOLCHAIN: local + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: '1.26.6' + - name: Install pinned scanner + run: GOBIN="$RUNNER_TEMP/vuln-bin" go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Scan + id: scan + run: bash scripts/security-report.sh legacy "$RUNNER_TEMP/vuln-bin/govulncheck" "$RUNNER_TEMP/security-report" + - name: Report incomplete scan + if: always() && steps.scan.outcome == 'skipped' + run: | + mkdir -p "$RUNNER_TEMP/security-report" + echo '## Security scan incomplete: checkout, setup or scanner installation failed. See job logs; no clean result is available.' | tee "$RUNNER_TEMP/security-report/summary.md" >> "$GITHUB_STEP_SUMMARY" + - name: Upload security report + if: always() + uses: actions/upload-artifact@v4 + with: + name: legacy-go-security + path: ${{ runner.temp }}/security-report + if-no-files-found: error diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5fc4c76..90bc782 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,5 +10,7 @@ permissions: contents: read jobs: + legacy-security: + uses: ./.github/workflows/legacy-security.yml verify: uses: ./.github/workflows/verify.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0e3fcd..1f167dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,8 @@ env: RELEASE_VERSION_INPUT: ${{ inputs.version }} jobs: + legacy-security: + uses: ./.github/workflows/legacy-security.yml verify: uses: ./.github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 221304a..d021e6f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -52,6 +52,7 @@ jobs: for script in scripts/*.sh; do bash -n "$script"; done bash scripts/prepare-release-test.sh python3 scripts/release-candidate-test.py + python3 scripts/security-report-test.py minimum: name: Minimum supported Go @@ -70,13 +71,9 @@ jobs: go test ./... go test -race ./... - vulnerability: - name: Vulnerabilities (${{ matrix.target }}) + security: + name: Security runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - target: [minimum, tooling] env: GOTOOLCHAIN: local steps: @@ -84,15 +81,20 @@ jobs: - uses: actions/setup-go@v7 with: go-version: '1.26.6' - - name: Install pinned scanner independently of consumer module + - name: Install pinned scanner run: GOBIN="$RUNNER_TEMP/vuln-bin" go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - - uses: actions/setup-go@v7 - if: matrix.target == 'minimum' - with: - go-version-file: go.mod - - name: Scan with selected analysis toolchain + - name: Scan + id: scan + run: bash scripts/security-report.sh strict "$RUNNER_TEMP/vuln-bin/govulncheck" "$RUNNER_TEMP/security-report" + - name: Report incomplete scan + if: always() && steps.scan.outcome == 'skipped' run: | - go version - "$RUNNER_TEMP/vuln-bin/govulncheck" -version - date -u - "$RUNNER_TEMP/vuln-bin/govulncheck" ./... + mkdir -p "$RUNNER_TEMP/security-report" + echo '## Security scan incomplete: checkout, setup or scanner installation failed. See job logs; no clean result is available.' | tee "$RUNNER_TEMP/security-report/summary.md" >> "$GITHUB_STEP_SUMMARY" + - name: Upload security report + if: always() + uses: actions/upload-artifact@v4 + with: + name: security + path: ${{ runner.temp }}/security-report + if-no-files-found: error diff --git a/docs/release-checklist.md b/docs/release-checklist.md index fcaca24..3d4feb0 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -8,7 +8,7 @@ workflow-verified release commit. ## Preflight - [ ] Work is merged to `master` and the branch is not moving during publication. -- [ ] Normal CI is green. +- [ ] Required quality, minimum-Go and Security checks are green; review the legacy diagnostic separately. - [ ] `CHANGELOG.md` has either a non-empty exact planned version section or non-empty `[Unreleased]` section. - [ ] README is current. @@ -81,15 +81,27 @@ a second local tag or move the published tag. ## Shared CI contract -Normal CI and Release both call `.github/workflows/verify.yml` at the caller's -revision. Its required jobs cover the existing project gate, minimum Go from -`go.mod`, and pinned govulncheck v1.8.0 on both minimum and tooling Go. The scanner -is installed with tooling Go, then executed with the selected analysis toolchain. -No vulnerability failures are suppressed. Resolving findings or raising the support -floor is a separate compatibility decision; a newer clean scan cannot replace a -failed minimum lane. A two-component Go directive selects that release family's -available patch; a three-component directive pins the specified minimum patch. -Each job logs the effective toolchain. Tooling remains on the pinned Go 1.26.6 lane. +Normal CI and Release call `.github/workflows/verify.yml` at the caller's +revision. Required checks are the existing quality gate, `Minimum supported Go` +(build/tests on the `go.mod` minimum), and `Security` (govulncheck v1.8.0, analysis +Go 1.26.6). Security fails on both reachable findings and scanner/setup errors. + +`legacy-security.yml` runs `Legacy Go security report` independently in CI and +Release. It installs the same pinned scanner with tooling Go, then scans with the +minimum Go. Findings produce a warning and an explicit diagnostic report; scanner, +setup or network errors fail the diagnostic job and report an incomplete/error +result. Neither outcome is a dependency of publication or a required branch check. +There is no blanket job-level error suppression. Summaries and artifacts distinguish +clean scans, findings and operational failures and record the effective toolchain. +A failed diagnostic can make the overall workflow red even though required checks +and publication succeed; inspect individual checks rather than the overall badge. + +The minimum Go directive promises build/API compatibility, not a vulnerability-free +old standard library. Consumers should use a maintained, patched Go toolchain. +A clean modern scan does not establish safety on the minimum version. Findings +still need review; raising the support floor is a separate compatibility decision. +A two-component Go directive selects that family's available patch; a +three-component directive pins the specified minimum patch. Project-specific checks remain required, including Squirrel's database, nested modules, differential/API and dependency gates where present. Modern tooling and @@ -119,7 +131,8 @@ The version input has no prefilled default to avoid reusing a previous release. Before merging this CI change, review branch protection required-check names: reusable workflow jobs have new check names. Configure rules to require the new -quality, minimum and both vulnerability lanes; do not disable existing protections +quality, minimum and Security lanes. Remove the obsolete vulnerability matrix +contexts only when their replacements are selected; do not require Legacy Go security report; do not disable existing protections without their replacements. Confirm the release bot can push its changelog commit. No local validation can establish those hosted repository settings. @@ -128,6 +141,7 @@ Local release regression checks: ```bash bash scripts/prepare-release-test.sh python3 scripts/release-candidate-test.py +python3 scripts/security-report-test.py ``` The tests reject heading-only/planned/finalized empty notes, duplicate versions, diff --git a/scripts/security-report-test.py b/scripts/security-report-test.py new file mode 100644 index 0000000..26dd7b3 --- /dev/null +++ b/scripts/security-report-test.py @@ -0,0 +1,48 @@ +"""Exercise scanner status handling without network access.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name('security-report.sh') + +class SecurityReportTest(unittest.TestCase): + def run_case(self, mode, scan_code, expected, status, preflight=False, missing=False, go_failure=False): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + scanner = root / 'scanner' + if not missing: + scanner.write_text(f'#!/bin/sh\nif [ "$1" = -version ]; then exit {1 if preflight else 0}; fi\necho scan-output\nexit {scan_code}\n') + scanner.chmod(0o755) + go = root / 'go' + go.write_text(f'#!/bin/sh\necho go-version-fixture\nexit {1 if go_failure else 0}\n') + go.chmod(0o755) + env = dict(os.environ, PATH=f'{tmp}:'+os.environ['PATH'], GITHUB_STEP_SUMMARY=str(root/'summary')) + result = subprocess.run(['bash', str(SCRIPT), mode, str(scanner), str(root/'report')], env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, expected, result.stdout+result.stderr) + self.assertIn(f'**{status}**', (root/'summary').read_text()) + self.assertIn('go-version-fixture', (root/'report/scan.log').read_text()) + if status == 'error': + self.assertIn('::error::', result.stdout) + if status == 'findings' and mode == 'legacy': + self.assertIn('::warning::', result.stdout) + + def test_result_matrix(self): + for mode in ['strict', 'legacy']: + for code, status in [(0, 'clean'), (3, 'findings'), (1, 'error'), (2, 'error'), (137, 'error')]: + with self.subTest(mode=mode, code=code): + self.run_case(mode, code, 0 if mode == 'legacy' and code == 3 else code, status) + + def test_failed_preflight(self): + for mode in ['strict', 'legacy']: + self.run_case(mode, 0, 1, 'error', preflight=True) + + def test_failed_go(self): + self.run_case('legacy', 0, 1, 'error', go_failure=True) + + def test_missing_scanner(self): + self.run_case('legacy', 0, 1, 'error', missing=True) + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/security-report.sh b/scripts/security-report.sh new file mode 100644 index 0000000..78942ba --- /dev/null +++ b/scripts/security-report.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# govulncheck v1.8.0 text mode: 0 = clean, 3 = findings, other = failure. +set -euo pipefail +mode=${1:?expected strict or legacy} +scanner=${2:?expected scanner path} +report=${3:?expected report directory} +[[ "$mode" == strict || "$mode" == legacy ]] || exit 2 +mkdir -p "$report" +status=error +code=1 +{ + date -u && + go version && + "$scanner" -version +} > "$report/scan.log" 2>&1 && { + set +e + "$scanner" ./... >> "$report/scan.log" 2>&1 + code=$? + set -e + case "$code" in + 0) status=clean ;; + 3) status=findings ;; + esac +} +cat "$report/scan.log" +{ + echo "## Go security: $mode" + echo + echo "Result: **$status** (exit $code)." + echo + if [[ "$mode" == legacy ]]; then + echo 'Diagnostic only. Minimum-Go build/tests remain required.' + echo 'A clean modern-Go scan does not establish safety on this older toolchain.' + fi + echo 'Full scanner output and effective toolchain are in the security report artifact.' +} > "$report/summary.md" +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat "$report/summary.md" >> "$GITHUB_STEP_SUMMARY" +fi +if [[ "$status" == findings && "$mode" == legacy ]]; then + echo '::warning::Legacy Go vulnerability findings; see security report.' + exit 0 +fi +if [[ "$status" == error ]]; then + echo '::error::Security scan failed or could not start; this is not a clean result.' +fi +exit "$code" From 4f9e5900755428cada2a9c4741143ddea4292f18 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Fri, 2 Oct 2026 11:25:04 +0000 Subject: [PATCH 3/3] ci: remove legacy Go security diagnostics --- .github/workflows/legacy-security.yml | 39 --------------------------- .github/workflows/main.yml | 2 -- .github/workflows/release.yml | 2 -- .github/workflows/verify.yml | 2 +- docs/release-checklist.md | 19 +++++-------- scripts/security-report-test.py | 20 ++++++-------- scripts/security-report.sh | 16 +++-------- 7 files changed, 19 insertions(+), 81 deletions(-) delete mode 100644 .github/workflows/legacy-security.yml diff --git a/.github/workflows/legacy-security.yml b/.github/workflows/legacy-security.yml deleted file mode 100644 index 73a65d3..0000000 --- a/.github/workflows/legacy-security.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Legacy Go security report - -on: - workflow_call: - -permissions: - contents: read - -jobs: - security: - name: Legacy Go security report - runs-on: ubuntu-24.04 - env: - GOTOOLCHAIN: local - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 - with: - go-version: '1.26.6' - - name: Install pinned scanner - run: GOBIN="$RUNNER_TEMP/vuln-bin" go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - name: Scan - id: scan - run: bash scripts/security-report.sh legacy "$RUNNER_TEMP/vuln-bin/govulncheck" "$RUNNER_TEMP/security-report" - - name: Report incomplete scan - if: always() && steps.scan.outcome == 'skipped' - run: | - mkdir -p "$RUNNER_TEMP/security-report" - echo '## Security scan incomplete: checkout, setup or scanner installation failed. See job logs; no clean result is available.' | tee "$RUNNER_TEMP/security-report/summary.md" >> "$GITHUB_STEP_SUMMARY" - - name: Upload security report - if: always() - uses: actions/upload-artifact@v4 - with: - name: legacy-go-security - path: ${{ runner.temp }}/security-report - if-no-files-found: error diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 90bc782..5fc4c76 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,5 @@ permissions: contents: read jobs: - legacy-security: - uses: ./.github/workflows/legacy-security.yml verify: uses: ./.github/workflows/verify.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f167dc..d0e3fcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,6 @@ env: RELEASE_VERSION_INPUT: ${{ inputs.version }} jobs: - legacy-security: - uses: ./.github/workflows/legacy-security.yml verify: uses: ./.github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index d021e6f..ad72fa1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -85,7 +85,7 @@ jobs: run: GOBIN="$RUNNER_TEMP/vuln-bin" go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - name: Scan id: scan - run: bash scripts/security-report.sh strict "$RUNNER_TEMP/vuln-bin/govulncheck" "$RUNNER_TEMP/security-report" + run: bash scripts/security-report.sh "$RUNNER_TEMP/vuln-bin/govulncheck" "$RUNNER_TEMP/security-report" - name: Report incomplete scan if: always() && steps.scan.outcome == 'skipped' run: | diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3d4feb0..4d5fc72 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -8,7 +8,7 @@ workflow-verified release commit. ## Preflight - [ ] Work is merged to `master` and the branch is not moving during publication. -- [ ] Required quality, minimum-Go and Security checks are green; review the legacy diagnostic separately. +- [ ] Required quality, minimum-Go and Security checks are green. - [ ] `CHANGELOG.md` has either a non-empty exact planned version section or non-empty `[Unreleased]` section. - [ ] README is current. @@ -86,15 +86,10 @@ revision. Required checks are the existing quality gate, `Minimum supported Go` (build/tests on the `go.mod` minimum), and `Security` (govulncheck v1.8.0, analysis Go 1.26.6). Security fails on both reachable findings and scanner/setup errors. -`legacy-security.yml` runs `Legacy Go security report` independently in CI and -Release. It installs the same pinned scanner with tooling Go, then scans with the -minimum Go. Findings produce a warning and an explicit diagnostic report; scanner, -setup or network errors fail the diagnostic job and report an incomplete/error -result. Neither outcome is a dependency of publication or a required branch check. -There is no blanket job-level error suppression. Summaries and artifacts distinguish -clean scans, findings and operational failures and record the effective toolchain. -A failed diagnostic can make the overall workflow red even though required checks -and publication succeed; inspect individual checks rather than the overall badge. +The security check retains a GitHub Summary and an artifact with scanner output +and the effective toolchain. It distinguishes findings from scanner/setup errors; +an incomplete scan is never treated as clean. CI and releases do not scan old Go +versions. A minimum-toolchain security audit can be performed manually when needed. The minimum Go directive promises build/API compatibility, not a vulnerability-free old standard library. Consumers should use a maintained, patched Go toolchain. @@ -132,8 +127,8 @@ The version input has no prefilled default to avoid reusing a previous release. Before merging this CI change, review branch protection required-check names: reusable workflow jobs have new check names. Configure rules to require the new quality, minimum and Security lanes. Remove the obsolete vulnerability matrix -contexts only when their replacements are selected; do not require Legacy Go security report; do not disable existing protections -without their replacements. Confirm the release bot can push its changelog commit. +contexts only when their replacements are selected. Do not disable existing +protections without their replacements. Confirm the release bot can push its changelog commit. No local validation can establish those hosted repository settings. Local release regression checks: diff --git a/scripts/security-report-test.py b/scripts/security-report-test.py index 26dd7b3..321d0a3 100644 --- a/scripts/security-report-test.py +++ b/scripts/security-report-test.py @@ -8,7 +8,7 @@ SCRIPT = Path(__file__).with_name('security-report.sh') class SecurityReportTest(unittest.TestCase): - def run_case(self, mode, scan_code, expected, status, preflight=False, missing=False, go_failure=False): + def run_case(self, scan_code, expected, status, preflight=False, missing=False, go_failure=False): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) scanner = root / 'scanner' @@ -19,30 +19,26 @@ def run_case(self, mode, scan_code, expected, status, preflight=False, missing=F go.write_text(f'#!/bin/sh\necho go-version-fixture\nexit {1 if go_failure else 0}\n') go.chmod(0o755) env = dict(os.environ, PATH=f'{tmp}:'+os.environ['PATH'], GITHUB_STEP_SUMMARY=str(root/'summary')) - result = subprocess.run(['bash', str(SCRIPT), mode, str(scanner), str(root/'report')], env=env, capture_output=True, text=True) + result = subprocess.run(['bash', str(SCRIPT), str(scanner), str(root/'report')], env=env, capture_output=True, text=True) self.assertEqual(result.returncode, expected, result.stdout+result.stderr) self.assertIn(f'**{status}**', (root/'summary').read_text()) self.assertIn('go-version-fixture', (root/'report/scan.log').read_text()) if status == 'error': self.assertIn('::error::', result.stdout) - if status == 'findings' and mode == 'legacy': - self.assertIn('::warning::', result.stdout) def test_result_matrix(self): - for mode in ['strict', 'legacy']: - for code, status in [(0, 'clean'), (3, 'findings'), (1, 'error'), (2, 'error'), (137, 'error')]: - with self.subTest(mode=mode, code=code): - self.run_case(mode, code, 0 if mode == 'legacy' and code == 3 else code, status) + for code, status in [(0, 'clean'), (3, 'findings'), (1, 'error'), (2, 'error'), (137, 'error')]: + with self.subTest(code=code): + self.run_case(code, code, status) def test_failed_preflight(self): - for mode in ['strict', 'legacy']: - self.run_case(mode, 0, 1, 'error', preflight=True) + self.run_case(0, 1, 'error', preflight=True) def test_failed_go(self): - self.run_case('legacy', 0, 1, 'error', go_failure=True) + self.run_case(0, 1, 'error', go_failure=True) def test_missing_scanner(self): - self.run_case('legacy', 0, 1, 'error', missing=True) + self.run_case(0, 1, 'error', missing=True) if __name__ == '__main__': unittest.main() diff --git a/scripts/security-report.sh b/scripts/security-report.sh index 78942ba..55cd9fe 100644 --- a/scripts/security-report.sh +++ b/scripts/security-report.sh @@ -1,10 +1,8 @@ #!/usr/bin/env bash # govulncheck v1.8.0 text mode: 0 = clean, 3 = findings, other = failure. set -euo pipefail -mode=${1:?expected strict or legacy} -scanner=${2:?expected scanner path} -report=${3:?expected report directory} -[[ "$mode" == strict || "$mode" == legacy ]] || exit 2 +scanner=${1:?expected scanner path} +report=${2:?expected report directory} mkdir -p "$report" status=error code=1 @@ -24,23 +22,15 @@ code=1 } cat "$report/scan.log" { - echo "## Go security: $mode" + echo "## Go security" echo echo "Result: **$status** (exit $code)." echo - if [[ "$mode" == legacy ]]; then - echo 'Diagnostic only. Minimum-Go build/tests remain required.' - echo 'A clean modern-Go scan does not establish safety on this older toolchain.' - fi echo 'Full scanner output and effective toolchain are in the security report artifact.' } > "$report/summary.md" if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then cat "$report/summary.md" >> "$GITHUB_STEP_SUMMARY" fi -if [[ "$status" == findings && "$mode" == legacy ]]; then - echo '::warning::Legacy Go vulnerability findings; see security report.' - exit 0 -fi if [[ "$status" == error ]]; then echo '::error::Security scan failed or could not start; this is not a clean result.' fi