From ac74ab10fce0160faa12ee3d0d4eeb7761c32c9c Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Tue, 25 Aug 2026 16:13:32 +0000 Subject: [PATCH 1/2] Parameterize shared Go release entrypoints Co-authored-by: c1-squire-dev[bot] --- .github/workflows/release.yaml | 41 ++++++++++ Makefile | 2 + README.md | 4 +- docs/release-workflow.md | 2 +- scripts/normalize-release-options.sh | 32 ++++++++ scripts/test-normalize-release-options.sh | 36 +++++++++ scripts/test-release-config-templates.py | 81 +++++++++++++++++++ .../.goreleaser-binaries-template.yaml.tmpl | 8 +- ...oreleaser-docker-lambda-template.yaml.tmpl | 2 +- .../.goreleaser-docker-oci-template.yaml.tmpl | 4 +- .../.goreleaser-windows-template.yaml.tmpl | 2 +- 11 files changed, 204 insertions(+), 10 deletions(-) create mode 100755 scripts/normalize-release-options.sh create mode 100755 scripts/test-normalize-release-options.sh create mode 100755 scripts/test-release-config-templates.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cf81795..32399e9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,6 +22,16 @@ on: type: string default: "" description: "Optional release storage path segment. Defaults to the repository name." + go_main_package: + required: false + type: string + default: "" + description: "Optional relative Go main package. Defaults to ./cmd/." + brew_tap: + required: false + type: string + default: "homebrew-baton" + description: "ConductorOne Homebrew tap repository name." lambda: required: false type: boolean @@ -88,6 +98,7 @@ concurrency: jobs: validate-inputs: runs-on: ubuntu-latest + steps: - name: Validate tag format env: @@ -174,8 +185,11 @@ jobs: runs-on: ubuntu-latest permissions: actions: read + contents: read outputs: ref: ${{ steps.workflow-version.outputs.sha }} + go_main_package: ${{ steps.release-options.outputs.go_main_package }} + brew_tap: ${{ steps.release-options.outputs.brew_tap }} steps: - name: Determine workflows ref id: workflow-version @@ -185,6 +199,28 @@ jobs: file-name: "release.yaml" github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout pinned connector workflows + uses: actions/checkout@v5 + with: + path: _workflows + repository: ConductorOne/github-workflows + ref: ${{ steps.workflow-version.outputs.sha }} + persist-credentials: false + + - name: Normalize release options + id: release-options + working-directory: _workflows + shell: bash + env: + GO_MAIN_PACKAGE_INPUT: ${{ inputs.go_main_package }} + BREW_TAP_INPUT: ${{ inputs.brew_tap }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + run: | + ./scripts/normalize-release-options.sh \ + "$REPOSITORY_NAME" \ + "$GO_MAIN_PACKAGE_INPUT" \ + "$BREW_TAP_INPUT" >> "$GITHUB_OUTPUT" + goreleaser-binaries: needs: determine-workflows-ref runs-on: macos-latest @@ -265,6 +301,8 @@ jobs: working-directory: _workflows env: REPO_NAME: ${{ github.event.repository.name }} + GO_MAIN_PACKAGE: ${{ needs.determine-workflows-ref.outputs.go_main_package }} + BREW_TAP: ${{ needs.determine-workflows-ref.outputs.brew_tap }} BREW_SKIP_UPLOAD: ${{ inputs.brew != true }} # For provenance predicate template WORKFLOWS_REF: ${{ needs.determine-workflows-ref.outputs.ref }} @@ -592,6 +630,7 @@ jobs: shell: bash env: REPO_NAME: ${{ github.event.repository.name }} + GO_MAIN_PACKAGE: ${{ needs.determine-workflows-ref.outputs.go_main_package }} WXS_PATH: ${{ steps.wxs.outputs.wxs_path }} WORKFLOWS_REF: ${{ needs.determine-workflows-ref.outputs.ref }} RELEASE_TAG: ${{ inputs.tag }} @@ -840,6 +879,7 @@ jobs: working-directory: _workflows env: REPO_NAME: ${{ github.event.repository.name }} + GO_MAIN_PACKAGE: ${{ needs.determine-workflows-ref.outputs.go_main_package }} DOCKERFILE_PATH: ../_workflows/_generated/Dockerfile DIST_DIR: dist/oci PUBLIC_ECR_PUBLISH_TAG: release-candidate-${{ github.run_id }}-${{ github.run_attempt }} @@ -898,6 +938,7 @@ jobs: working-directory: _workflows env: REPO_NAME: ${{ github.event.repository.name }} + GO_MAIN_PACKAGE: ${{ needs.determine-workflows-ref.outputs.go_main_package }} DOCKERFILE_LAMBDA_PATH: ../_workflows/_generated/Dockerfile.lambda DIST_DIR: dist/lambda run: | diff --git a/Makefile b/Makefile index 82bd5d9..b6d5feb 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,8 @@ test-go: .PHONY: test-scripts test-scripts: bash scripts/test-derive-iam-role-name.sh + bash scripts/test-normalize-release-options.sh + python3 scripts/test-release-config-templates.py bash scripts/test-s3-release-uploads.sh if command -v pwsh >/dev/null 2>&1; then pwsh -NoProfile -File scripts/test-s3-release-uploads.ps1; else echo "pwsh not found; skipping PowerShell S3 release upload tests"; fi diff --git a/README.md b/README.md index 1466ba1..7202af7 100644 --- a/README.md +++ b/README.md @@ -76,13 +76,15 @@ The release workflow accepts the following input parameters: |-|-|-|-| | `tag` | Yes | - | The release tag (must be valid semver with `v` prefix, e.g., `v1.0.0`) | | `release_storage_name` | No | `""` | Optional S3 release path segment matching `^[a-z][a-z0-9-]{0,99}$`; defaults to the repository name | +| `go_main_package` | No | `""` | Relative Go main package. Defaults to `./cmd/`; use `./` for a root command package. | +| `brew_tap` | No | `homebrew-baton` | ConductorOne Homebrew tap repository name. Must not contain a path separator. | | `lambda` | No | `true` | Whether to release with Lambda image support | | `docker` | No | `true` | Whether to release with Docker image support | | `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) | | `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context | | `msi` | No | `true` | Whether to build MSI Windows installers | | `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) | -| `brew` | No | `true` | Whether to publish a Homebrew formula to the public `conductorone/homebrew-baton` tap | +| `brew` | No | `true` | Whether to publish a Homebrew formula to the selected public `conductorone` tap | 2. Ensure your repository has the following secrets configured: diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 52685fe..78814fa 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -32,7 +32,7 @@ Validates workflow inputs before proceeding: ### determine-workflows-ref -Resolves the exact SHA of the shared workflow being used. This pinned reference is embedded in all provenance attestations, ensuring verifiability. +Resolves the exact SHA of the shared workflow, then uses the pinned workflow source to normalize `go_main_package` and `brew_tap`. An omitted main package becomes `./cmd/`; a root package uses `./`. The workflow rejects non-relative package paths and tap values containing a path separator. The pinned reference is embedded in all provenance attestations, ensuring verifiability. ### goreleaser-binaries (macOS) diff --git a/scripts/normalize-release-options.sh b/scripts/normalize-release-options.sh new file mode 100755 index 0000000..c3cfad6 --- /dev/null +++ b/scripts/normalize-release-options.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +repository_name="$1" +go_main_package="$2" +brew_tap="$3" + +if [ -z "$go_main_package" ]; then + go_main_package="./cmd/${repository_name}" +fi +relative_package_pattern='^\./([A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*$' +if [[ "$go_main_package" != "./" && ! "$go_main_package" =~ $relative_package_pattern ]]; then + echo "go_main_package must be ./ or a relative package path without empty, . or .. components: $go_main_package" >&2 + exit 1 +fi + +if [ -z "$brew_tap" ]; then + brew_tap="homebrew-baton" +fi +brew_tap_pattern='^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$' +if [[ ! "$brew_tap" =~ $brew_tap_pattern ]]; then + echo "brew_tap must be a GitHub repository name without a path separator: $brew_tap" >&2 + exit 1 +fi + +printf 'go_main_package=%s\n' "$go_main_package" +printf 'brew_tap=%s\n' "$brew_tap" diff --git a/scripts/test-normalize-release-options.sh b/scripts/test-normalize-release-options.sh new file mode 100755 index 0000000..774cb97 --- /dev/null +++ b/scripts/test-normalize-release-options.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="${script_dir}/normalize-release-options.sh" + +assert_output() { + local want="$1" + shift + local got + got="$(bash "$script" "$@")" + if [ "$got" != "$want" ]; then + echo "got: $got" >&2 + echo "want: $want" >&2 + exit 1 + fi +} + +assert_failure() { + if bash "$script" "$@" >/dev/null 2>&1; then + echo "expected failure: $script $*" >&2 + exit 1 + fi +} + +assert_output $'go_main_package=./cmd/bridge-client\nbrew_tap=homebrew-baton' bridge-client "" "" +assert_output $'go_main_package=./\nbrew_tap=homebrew-cone' c1i ./ homebrew-cone +assert_output $'go_main_package=./cmd/release\nbrew_tap=homebrew-baton' c1i ./cmd/release homebrew-baton + +assert_failure c1i /cmd/c1i homebrew-baton +assert_failure c1i ../cmd/c1i homebrew-baton +assert_failure c1i ./cmd/../c1i homebrew-baton +assert_failure c1i ./cmd//c1i homebrew-baton +assert_failure c1i ./cmd/ homebrew-baton +assert_failure c1i ./ owner/homebrew-cone +assert_failure c1i ./ 'homebrew cone' diff --git a/scripts/test-release-config-templates.py b/scripts/test-release-config-templates.py new file mode 100755 index 0000000..4d2f3d1 --- /dev/null +++ b/scripts/test-release-config-templates.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Verify shared GoReleaser templates consume normalized release options.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATES = { + "binaries": ROOT / "templates/.goreleaser-binaries-template.yaml.tmpl", + "windows": ROOT / "templates/.goreleaser-windows-template.yaml.tmpl", + "oci": ROOT / "templates/.goreleaser-docker-oci-template.yaml.tmpl", + "lambda": ROOT / "templates/.goreleaser-docker-lambda-template.yaml.tmpl", +} +VARIABLE = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))") + + +def render(path: Path, values: dict[str, str]) -> str: + source = path.read_text() + + def substitute(match: re.Match[str]) -> str: + name = match.group(1) or match.group(2) + if name not in values: + raise AssertionError(f"{path.name} references unprovided variable {name}") + return values[name] + + return VARIABLE.sub(substitute, source) + + +def assert_contains(rendered: str, needle: str, label: str) -> None: + if needle not in rendered: + raise AssertionError(f"{label} missing {needle!r}") + + +def assert_main(rendered: str, package: str, count: int, label: str) -> None: + needle = f'main: "{package}"' + actual = rendered.count(needle) + if actual != count: + raise AssertionError(f"{label} has {actual} {needle!r} entries, want {count}") + + +def verify_case(go_main_package: str, brew_tap: str) -> None: + values = { + "REPO_NAME": "bridge-client", + "GO_MAIN_PACKAGE": go_main_package, + "WXS_PATH": "app.wxs", + "BREW_TAP": brew_tap, + "BREW_SKIP_UPLOAD": "false", + "DIST_DIR": "dist/test", + "DOCKERFILE_PATH": "Dockerfile", + "DOCKERFILE_LAMBDA_PATH": "Dockerfile.lambda", + "PUBLIC_ECR_PUBLISH_TAG": "candidate", + "EXTRA_FILES_BLOCK": "", + } + rendered = {name: render(path, values) for name, path in TEMPLATES.items()} + + assert_main(rendered["binaries"], go_main_package, 3, "binaries template") + assert_main(rendered["windows"], go_main_package, 1, "windows template") + assert_main(rendered["oci"], go_main_package, 1, "OCI template") + assert_main(rendered["lambda"], go_main_package, 1, "Lambda template") + assert_contains(rendered["binaries"], f'name: "{brew_tap}"', "binaries template") + + +def main() -> int: + verify_case("./cmd/bridge-client", "homebrew-baton") + verify_case("./", "homebrew-cone") + + for path in TEMPLATES.values(): + if "./cmd/${REPO_NAME}" in path.read_text(): + raise AssertionError(f"{path.name} retains an unparameterized Go main package") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except AssertionError as err: + print(f"test-release-config-templates: {err}", file=sys.stderr) + raise SystemExit(1) diff --git a/templates/.goreleaser-binaries-template.yaml.tmpl b/templates/.goreleaser-binaries-template.yaml.tmpl index da6f509..922c093 100644 --- a/templates/.goreleaser-binaries-template.yaml.tmpl +++ b/templates/.goreleaser-binaries-template.yaml.tmpl @@ -6,7 +6,7 @@ builds: env: - CGO_ENABLED=0 id: linux - main: ./cmd/${REPO_NAME} + main: "${GO_MAIN_PACKAGE}" goos: - linux goarch: @@ -17,7 +17,7 @@ builds: env: - CGO_ENABLED=0 id: macos-amd64 - main: ./cmd/${REPO_NAME} + main: "${GO_MAIN_PACKAGE}" goos: - darwin goarch: @@ -29,7 +29,7 @@ builds: env: - CGO_ENABLED=0 id: macos-arm64 - main: ./cmd/${REPO_NAME} + main: "${GO_MAIN_PACKAGE}" goos: - darwin goarch: @@ -90,7 +90,7 @@ signs: brews: - repository: owner: conductorone - name: homebrew-baton + name: "${BREW_TAP}" directory: Formula skip_upload: "${BREW_SKIP_UPLOAD}" homepage: https://conductorone.com diff --git a/templates/.goreleaser-docker-lambda-template.yaml.tmpl b/templates/.goreleaser-docker-lambda-template.yaml.tmpl index f4383f3..b5b5e35 100644 --- a/templates/.goreleaser-docker-lambda-template.yaml.tmpl +++ b/templates/.goreleaser-docker-lambda-template.yaml.tmpl @@ -8,7 +8,7 @@ builds: env: - CGO_ENABLED=0 id: linux - main: ./cmd/${REPO_NAME} + main: "${GO_MAIN_PACKAGE}" goos: - linux goarch: diff --git a/templates/.goreleaser-docker-oci-template.yaml.tmpl b/templates/.goreleaser-docker-oci-template.yaml.tmpl index 86c7540..dce2abc 100644 --- a/templates/.goreleaser-docker-oci-template.yaml.tmpl +++ b/templates/.goreleaser-docker-oci-template.yaml.tmpl @@ -2,7 +2,7 @@ ## GoReleaser >= 2.12 required for dockers_v2 ## ## Template variables (substituted via envsubst): -## REPO_NAME, DIST_DIR, DOCKERFILE_PATH, PUBLIC_ECR_PUBLISH_TAG, EXTRA_FILES_BLOCK +## REPO_NAME, GO_MAIN_PACKAGE, DIST_DIR, DOCKERFILE_PATH, PUBLIC_ECR_PUBLISH_TAG, EXTRA_FILES_BLOCK version: 2 project_name: "${REPO_NAME}" dist: "${DIST_DIR}" @@ -11,7 +11,7 @@ builds: env: - CGO_ENABLED=0 id: linux - main: ./cmd/${REPO_NAME} + main: "${GO_MAIN_PACKAGE}" goos: - linux goarch: diff --git a/templates/.goreleaser-windows-template.yaml.tmpl b/templates/.goreleaser-windows-template.yaml.tmpl index 5f3aad8..3ffe581 100644 --- a/templates/.goreleaser-windows-template.yaml.tmpl +++ b/templates/.goreleaser-windows-template.yaml.tmpl @@ -7,7 +7,7 @@ builds: env: - CGO_ENABLED=0 id: windows - main: ./cmd/${REPO_NAME} + main: "${GO_MAIN_PACKAGE}" goos: - windows goarch: From 831ceae31ae26248795bc8309e204905bed505ad Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Tue, 25 Aug 2026 16:27:20 +0000 Subject: [PATCH 2/2] Add opt-in release source integrity checks Co-authored-by: c1-squire-dev[bot] --- .github/workflows/release.yaml | 105 ++++++++++++++++++++++++++++++++- README.md | 3 + docs/release-workflow.md | 9 +++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 32399e9..299331d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -32,6 +32,21 @@ on: type: string default: "homebrew-baton" description: "ConductorOne Homebrew tap repository name." + go_source_hygiene: + required: false + type: boolean + default: false + description: "Run go generate and go mod tidy, then require a clean source tree." + go_vulnerability_scan: + required: false + type: boolean + default: false + description: "Run govulncheck for Linux, macOS, and Windows before release." + verify_module_version: + required: false + type: boolean + default: false + description: "Require generated binaries to embed the release tag as their module version." lambda: required: false type: boolean @@ -221,8 +236,73 @@ jobs: "$GO_MAIN_PACKAGE_INPUT" \ "$BREW_TAP_INPUT" >> "$GITHUB_OUTPUT" - goreleaser-binaries: + release-preflight: needs: determine-workflows-ref + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout caller repo + if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true + uses: actions/checkout@v5 + with: + path: _caller + repository: ${{ github.event.repository.full_name }} + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify caller checkout matches release tag + if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true + working-directory: _caller + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag_commit="$(git rev-list -n 1 "refs/tags/$RELEASE_TAG")" + head_commit="$(git rev-parse HEAD)" + if [ "$head_commit" != "$tag_commit" ]; then + echo "::error::Checked out $head_commit but refs/tags/$RELEASE_TAG resolves to $tag_commit" + exit 1 + fi + + - name: Set up Go for caller + if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true + uses: actions/setup-go@v6 + with: + go-version-file: "_caller/go.mod" + cache: false + + - name: Verify generated source and module hygiene + if: inputs.go_source_hygiene == true + working-directory: _caller + shell: bash + run: | + set -euo pipefail + go generate ./... + go mod tidy + if [ -n "$(git status --porcelain)" ]; then + echo "::error::go generate or go mod tidy modified the release source tree" + git status --porcelain + exit 1 + fi + + - name: Check for known vulnerabilities + if: inputs.go_vulnerability_scan == true + working-directory: _caller + shell: bash + env: + GOTOOLCHAIN: auto + run: | + set -euo pipefail + go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 + for goos in linux darwin windows; do + GOOS="$goos" govulncheck ./... + done + + goreleaser-binaries: + needs: [determine-workflows-ref, release-preflight] runs-on: macos-latest permissions: contents: read @@ -348,6 +428,25 @@ jobs: AC_PASSWORD: ${{ secrets.AC_PASSWORD }} AC_PROVIDER: ${{ secrets.AC_PROVIDER }} + - name: Verify binary module version + if: inputs.verify_module_version == true + working-directory: _caller + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + run: | + set -euo pipefail + binary="$(find dist -type f -name "$REPOSITORY_NAME" -print -quit)" + if [ -z "$binary" ]; then + echo "::error::No generated $REPOSITORY_NAME binary found for module version verification" + exit 1 + fi + module_version="$(go version -m "$binary" | awk '$1 == "mod" { print $3; exit }')" + if [ "$module_version" != "$RELEASE_TAG" ]; then + echo "::error::Generated binary module version is '$module_version', expected '$RELEASE_TAG'" + exit 1 + fi - name: Generate SLSA provenance for archives working-directory: _workflows env: @@ -519,7 +618,7 @@ jobs: goreleaser-windows: if: inputs.msi == true - needs: determine-workflows-ref + needs: [determine-workflows-ref, release-preflight] runs-on: windows-latest permissions: contents: read @@ -804,7 +903,7 @@ jobs: goreleaser-docker: if: inputs.docker == true || inputs.lambda == true - needs: determine-workflows-ref + needs: [determine-workflows-ref, release-preflight] permissions: id-token: write contents: read diff --git a/README.md b/README.md index 7202af7..d1767b0 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ The release workflow accepts the following input parameters: | `release_storage_name` | No | `""` | Optional S3 release path segment matching `^[a-z][a-z0-9-]{0,99}$`; defaults to the repository name | | `go_main_package` | No | `""` | Relative Go main package. Defaults to `./cmd/`; use `./` for a root command package. | | `brew_tap` | No | `homebrew-baton` | ConductorOne Homebrew tap repository name. Must not contain a path separator. | +| `go_source_hygiene` | No | `false` | Run `go generate ./...` and `go mod tidy`; fail if either changes the tagged source tree. | +| `go_vulnerability_scan` | No | `false` | Run `govulncheck` for Linux, macOS, and Windows before release. | +| `verify_module_version` | No | `false` | Require a generated binary to embed the release tag as its Go module version. | | `lambda` | No | `true` | Whether to release with Lambda image support | | `docker` | No | `true` | Whether to release with Docker image support | | `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) | diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 78814fa..89d4ac8 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -34,6 +34,15 @@ Validates workflow inputs before proceeding: Resolves the exact SHA of the shared workflow, then uses the pinned workflow source to normalize `go_main_package` and `brew_tap`. An omitted main package becomes `./cmd/`; a root package uses `./`. The workflow rejects non-relative package paths and tap values containing a path separator. The pinned reference is embedded in all provenance attestations, ensuring verifiability. +### release-preflight + +Runs opted-in source integrity checks against the exact tagged caller source before any build job: + +- `go_source_hygiene` runs `go generate ./...` and `go mod tidy`, then rejects a changed tree. +- `go_vulnerability_scan` runs `govulncheck` for Linux, macOS, and Windows. + +`verify_module_version` checks a generated binary after GoReleaser and before provenance or artifact upload. It requires the embedded Go module version to equal the release tag. + ### goreleaser-binaries (macOS) Builds and signs binary archives for macOS and Linux: