diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bbd5173..922bdf6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -37,6 +37,16 @@ on: type: string default: "" description: "Comma-separated list of extra files/directories from the caller repo to include in the Docker build context (e.g., 'java,config'). Only valid when dockerfile_template is set." + msi: + required: false + type: boolean + default: true + description: "Whether to build MSI Windows installers." + msi_wxs_path: + required: false + type: string + default: "" + description: "Path to a custom WXS file in the caller repo for MSI generation (relative to repo root). If not provided, uses default template." secrets: RELENG_GITHUB_TOKEN: required: true @@ -50,6 +60,9 @@ on: required: true DATADOG_API_KEY: required: true + GORELEASER_PRO_KEY: + required: false + description: "GoReleaser Pro license key for MSI builds. Required when msi is true." env: CDN_BASE_URL: "https://dist.conductorone.com" @@ -84,6 +97,25 @@ jobs: echo "::error::docker_extra_files can only be used when dockerfile_template is set" exit 1 + - name: Validate msi_wxs_path has no path traversal + if: inputs.msi_wxs_path != '' + run: | + WXS_PATH="${{ inputs.msi_wxs_path }}" + if [[ "$WXS_PATH" == /* ]] || [[ "$WXS_PATH" == *".."* ]]; then + echo "::error::msi_wxs_path must be a relative path without '..' traversal. Got: $WXS_PATH" + exit 1 + fi + + - name: Validate GORELEASER_PRO_KEY when msi enabled + if: inputs.msi == true + env: + HAS_KEY: ${{ secrets.GORELEASER_PRO_KEY != '' }} + run: | + if [ "$HAS_KEY" != "true" ]; then + echo "::error::GORELEASER_PRO_KEY secret is required when msi is true" + exit 1 + fi + determine-workflows-ref: needs: validate-inputs runs-on: ubuntu-latest @@ -109,6 +141,7 @@ jobs: outputs: s3_directory: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} binaries_manifest: ${{ steps.generate-binaries-manifest.outputs.binaries_manifest }} + binaries_checksums: ${{ steps.output-checksums.outputs.checksums }} steps: - name: Checkout caller repo uses: actions/checkout@v5 @@ -214,23 +247,11 @@ jobs: --bundle "${CALLER_DIST}/${BASENAME}.provenance.sigstore.json" \ "$artifact" > /dev/null echo "✅ Created ${BASENAME}.provenance.sigstore.json" - ((PROVENANCE_COUNT++)) + ((PROVENANCE_COUNT++)) || true done - # Also generate provenance for checksums file - for checksums in "${CALLER_DIST}"/*checksums.txt; do - [ -f "$checksums" ] || continue - BASENAME=$(basename "$checksums") - echo "Generating provenance for: $BASENAME" - cosign attest-blob \ - --yes \ - --predicate "${GENERATED_DIR}/predicate.json" \ - --type slsaprovenance1 \ - --bundle "${CALLER_DIST}/${BASENAME}.provenance.sigstore.json" \ - "$checksums" > /dev/null - echo "✅ Created ${BASENAME}.provenance.sigstore.json" - ((PROVENANCE_COUNT++)) - done + # Note: checksums provenance is generated in record-connector-registry job + # after merging with Windows hashes echo "Generated provenance bundles: ${PROVENANCE_COUNT}" if [ "$PROVENANCE_COUNT" -eq 0 ]; then @@ -275,7 +296,7 @@ jobs: --bundle "${ARCHIVE}.sbom.sigstore.json" \ "$ARCHIVE" > /dev/null echo "✅ Created $(basename "$ARCHIVE").sbom.sigstore.json" - ((SIGNED_COUNT++)) + ((SIGNED_COUNT++)) || true done echo "Generated SBOM bundles: ${SIGNED_COUNT}" @@ -338,6 +359,316 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" + - name: Output checksums for merging + id: output-checksums + working-directory: _caller + run: | + # Find the checksums file generated by GoReleaser + CHECKSUMS_FILE=$(ls dist/*checksums*.txt 2>/dev/null | head -1) + if [ -z "$CHECKSUMS_FILE" ]; then + echo "::error::No checksums file found" + exit 1 + fi + + echo "Found checksums file: $CHECKSUMS_FILE" + + # Output checksums content for merging with Windows hashes in registry job + # Use randomized delimiter to prevent injection via filenames containing "EOF" + DELIM="CHECKSUMS_$(openssl rand -hex 8)" + { + echo "checksums<<${DELIM}" + cat "$CHECKSUMS_FILE" + echo "${DELIM}" + } >> "$GITHUB_OUTPUT" + + goreleaser-windows: + if: inputs.msi == true + needs: determine-workflows-ref + runs-on: windows-latest + permissions: + contents: read + id-token: write + outputs: + windows_manifest: ${{ steps.generate-windows-manifest.outputs.windows_manifest }} + steps: + - name: Checkout caller repo + uses: actions/checkout@v5 + with: + path: _caller + repository: ${{ github.event.repository.full_name }} + fetch-depth: 0 + + - name: Checkout connector workflows + uses: actions/checkout@v5 + with: + path: _workflows + repository: ConductorOne/github-workflows + ref: ${{ needs.determine-workflows-ref.outputs.ref }} + + - name: Set up Go for caller + uses: actions/setup-go@v6 + with: + go-version-file: "_caller/go.mod" + + - name: Generate or locate WXS file + id: wxs + shell: pwsh + env: + REPO_NAME: ${{ github.event.repository.name }} + CUSTOM_WXS: ${{ inputs.msi_wxs_path }} + run: | + New-Item -ItemType Directory -Force -Path "_workflows/_generated" + + if ($env:CUSTOM_WXS -ne "") { + # Use custom WXS from caller repo + $wxsPath = "_caller/$env:CUSTOM_WXS" + if (-not (Test-Path $wxsPath)) { + Write-Error "Custom WXS file not found: $env:CUSTOM_WXS" + exit 1 + } + Write-Host "Using custom WXS: $wxsPath" + Copy-Item $wxsPath "_workflows/_generated/app.wxs" + "wxs_path=../_workflows/_generated/app.wxs" >> $env:GITHUB_OUTPUT + } else { + # Generate WXS from default template with deterministic UpgradeCode + Write-Host "Using default WXS template" + + # Generate deterministic UUID v5 from repo name using Python's standard library + # Uses URL namespace (6ba7b810-9dad-11d1-80b4-00c04fd430c8) per RFC 4122 + $upgradeCode = (python -c "import uuid; print(str(uuid.uuid5(uuid.NAMESPACE_URL, '$env:REPO_NAME')).upper())") + + Write-Host "Generated UpgradeCode for $env:REPO_NAME`: $upgradeCode" + + # Read template and substitute UpgradeCode + $template = Get-Content "_workflows/templates/.wxs-default-template.wxs" -Raw + $template = $template -replace '\$\{UPGRADE_CODE\}', $upgradeCode + $template | Out-File "_workflows/_generated/app.wxs" -Encoding utf8 + + "wxs_path=../_workflows/_generated/app.wxs" >> $env:GITHUB_OUTPUT + } + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Download syft + uses: anchore/sbom-action/download-syft@v0 + + - name: Generate configs for Windows + working-directory: _workflows + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + WXS_PATH: ${{ steps.wxs.outputs.wxs_path }} + WORKFLOWS_REF: ${{ needs.determine-workflows-ref.outputs.ref }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + export BUILD_STARTED_ON=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Generate GoReleaser config + envsubst < templates/.goreleaser-windows-template.yaml.tmpl | tee "_generated/.goreleaser.windows.yaml" + + # Generate provenance predicate + envsubst < templates/.slsa-provenance-predicate-template.json.tmpl | tee "_generated/predicate.json" + + - name: Run GoReleaser for Windows + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser-pro + workdir: _caller + version: "~> v2.13" + args: release --clean --skip=publish --config ../_workflows/_generated/.goreleaser.windows.yaml + env: + GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }} + GORELEASER_KEY: ${{ secrets.GORELEASER_PRO_KEY }} + + - name: Flatten MSI directory structure + shell: pwsh + run: | + # GoReleaser Pro puts MSI files in dist/msi// subdirectory + # Copy all files to dist root to match binaries job pattern + $msiDir = "_caller/dist/msi" + if (Test-Path $msiDir) { + Get-ChildItem $msiDir -Recurse -File | ForEach-Object { + Write-Host "Copying $($_.Name) to dist root" + Copy-Item $_.FullName -Destination "_caller/dist/" + } + Write-Host "✅ Flattened MSI directory structure" + } + + - name: Generate SLSA provenance for Windows artifacts + working-directory: _workflows + shell: bash + env: + CALLER_DIST: ../_caller/dist + run: | + set -euo pipefail + + PROVENANCE_COUNT=0 + + # Find all downloadable archives (zip and msi files) + # MSI files are flattened to dist root by previous step + for artifact in "${CALLER_DIST}"/*.zip "${CALLER_DIST}"/*.msi; do + [ -f "$artifact" ] || continue + [[ "$artifact" == *checksums* ]] && continue + + BASENAME=$(basename "$artifact") + echo "Generating provenance for: $BASENAME" + cosign attest-blob \ + --yes \ + --predicate "_generated/predicate.json" \ + --type slsaprovenance1 \ + --bundle "${CALLER_DIST}/${BASENAME}.provenance.sigstore.json" \ + "$artifact" > /dev/null + echo "✅ Created ${BASENAME}.provenance.sigstore.json" + ((PROVENANCE_COUNT++)) || true + done + + echo "Generated provenance bundles: ${PROVENANCE_COUNT}" + if [ "$PROVENANCE_COUNT" -eq 0 ]; then + echo "::error::No provenance bundles were generated - this indicates a build problem" + exit 1 + fi + ls "${CALLER_DIST}"/*.provenance.sigstore.json + + - name: Sign SBOMs as attestation bundles + working-directory: _workflows + shell: bash + env: + CALLER_DIST: ../_caller/dist + run: | + set -euo pipefail + + SIGNED_COUNT=0 + + # Find all SBOM files generated by GoReleaser (syft) + # All files are in dist root (MSI files flattened by previous step) + for sbom in "${CALLER_DIST}"/*.sbom.json; do + [ -f "$sbom" ] || continue + + SBOM_BASENAME=$(basename "$sbom") + ARCHIVE_NAME="${SBOM_BASENAME%.sbom.json}" + ARCHIVE="${CALLER_DIST}/${ARCHIVE_NAME}" + + if [ ! -f "$ARCHIVE" ]; then + echo "::error::Could not find archive for SBOM: $sbom (expected: $ARCHIVE)" + exit 1 + fi + + echo "Signing SBOM for: $(basename "$ARCHIVE")" + cosign attest-blob \ + --yes \ + --predicate "$sbom" \ + --type https://spdx.dev/Document \ + --bundle "${ARCHIVE}.sbom.sigstore.json" \ + "$ARCHIVE" > /dev/null + echo "✅ Created $(basename "$ARCHIVE").sbom.sigstore.json" + ((SIGNED_COUNT++)) || true + done + + echo "Generated SBOM bundles: ${SIGNED_COUNT}" + ls "${CALLER_DIST}"/*.sbom.sigstore.json 2>/dev/null || echo "ℹ️ No SBOM bundles generated (GoReleaser may not have generated SBOMs)" + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v5 + with: + role-to-assume: arn:aws:iam::025044153841:role/GHA-Artifacts-${{ github.event.repository.owner.login }}-${{ github.event.repository.name }} + aws-region: us-west-2 + + - name: Calculate S3 directory + id: s3-directory + shell: pwsh + run: | + $org = "${{ github.event.repository.owner.login }}" + $repo = "${{ github.event.repository.name }}" + $tag = "${{ inputs.tag }}" + "S3_DIRECTORY=releases/$org/$repo/$tag" >> $env:GITHUB_OUTPUT + + - name: Upload Windows artifacts to S3 + shell: pwsh + env: + S3_BUCKET: ${{ env.S3_BUCKET }} + S3_DIRECTORY: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} + run: | + # Upload zip files + $zipFiles = Get-ChildItem "_caller/dist/*.zip" -ErrorAction SilentlyContinue + foreach ($zip in $zipFiles) { + Write-Host "Uploading $($zip.Name) to S3..." + aws s3 cp $zip.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($zip.Name)" ` + --cache-control "public,max-age=31536000,immutable" ` + --content-type "application/zip" + } + + # Upload MSI files (flattened to dist root by earlier step) + $msiFiles = Get-ChildItem "_caller/dist/*.msi" -ErrorAction SilentlyContinue + foreach ($msi in $msiFiles) { + Write-Host "Uploading $($msi.Name) to S3..." + aws s3 cp $msi.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($msi.Name)" ` + --cache-control "public,max-age=31536000,immutable" ` + --content-type "application/x-msi" + } + + # Upload signatures (.sig files) + $sigFiles = Get-ChildItem "_caller/dist/*.sig" -ErrorAction SilentlyContinue + foreach ($sig in $sigFiles) { + Write-Host "Uploading $($sig.Name) to S3..." + aws s3 cp $sig.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($sig.Name)" ` + --cache-control "public,max-age=31536000,immutable" ` + --content-type "application/octet-stream" + } + + # Upload certificates (.cert files) + $certFiles = Get-ChildItem "_caller/dist/*.cert" -ErrorAction SilentlyContinue + foreach ($cert in $certFiles) { + Write-Host "Uploading $($cert.Name) to S3..." + aws s3 cp $cert.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($cert.Name)" ` + --cache-control "public,max-age=31536000,immutable" ` + --content-type "application/x-pem-file" + } + + # Upload SBOM json files (before attestation signing) + $sbomFiles = Get-ChildItem "_caller/dist/*.sbom.json" -ErrorAction SilentlyContinue + foreach ($sbom in $sbomFiles) { + Write-Host "Uploading $($sbom.Name) to S3..." + aws s3 cp $sbom.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($sbom.Name)" ` + --cache-control "public,max-age=31536000,immutable" ` + --content-type "application/json" + } + + # Upload attestation bundles (provenance and SBOM sigstore bundles) + $bundles = Get-ChildItem "_caller/dist/*.sigstore.json" -ErrorAction SilentlyContinue + foreach ($bundle in $bundles) { + Write-Host "Uploading $($bundle.Name) to S3..." + aws s3 cp $bundle.FullName "s3://$env:S3_BUCKET/$env:S3_DIRECTORY/$($bundle.Name)" ` + --cache-control "public,max-age=31536000,immutable" ` + --content-type "application/json" + } + + - name: Set up Go for workflows tools + uses: actions/setup-go@v6 + with: + go-version-file: "_workflows/go.mod" + + - name: Generate Windows manifest + id: generate-windows-manifest + working-directory: _workflows + shell: bash + env: + CDN_BASE_URL: ${{ env.CDN_BASE_URL }} + S3_DIRECTORY: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} + run: | + # Use Go tool for type-safe manifest generation + MANIFEST=$(go run ./cmd/generate-windows-manifest \ + -dist-dir "../_caller/dist" \ + -cdn-base-url "$CDN_BASE_URL" \ + -s3-directory "$S3_DIRECTORY") + + echo "Windows manifest: $MANIFEST" + { + echo "windows_manifest<> "$GITHUB_OUTPUT" + goreleaser-docker: if: inputs.docker == true || inputs.lambda == true needs: determine-workflows-ref @@ -577,11 +908,10 @@ jobs: done < "$DIGEST_FILE" record-connector-registry: - # require binaries and docker to complete before recording, since we include both in the manifest - # use explicit if condition to run when goreleaser-docker is skipped (docker=false && lambda=false) + # require binaries to succeed; windows and docker may be skipped (msi=false, docker=false && lambda=false) # see: https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution - if: ${{ !cancelled() && needs.goreleaser-binaries.result == 'success' }} - needs: [determine-workflows-ref, goreleaser-binaries, goreleaser-docker] + if: ${{ !cancelled() && needs.goreleaser-binaries.result == 'success' && (needs.goreleaser-windows.result == 'success' || needs.goreleaser-windows.result == 'skipped') }} + needs: [determine-workflows-ref, goreleaser-binaries, goreleaser-windows, goreleaser-docker] permissions: id-token: write contents: read @@ -599,16 +929,18 @@ jobs: with: go-version-file: "_workflows/go.mod" - - name: Merge binaries and images manifests + - name: Merge binaries, Windows, and images manifests working-directory: _workflows env: BINARIES_MANIFEST: ${{ needs.goreleaser-binaries.outputs.binaries_manifest }} + WINDOWS_MANIFEST: ${{ needs.goreleaser-windows.outputs.windows_manifest }} IMAGES_MANIFEST: ${{ needs.goreleaser-docker.outputs.images_manifest }} OUTPUT_DIR: _output run: | mkdir -p "${OUTPUT_DIR}" go run ./cmd/merge-manifests \ -binaries-manifest "$BINARIES_MANIFEST" \ + -windows-manifest "$WINDOWS_MANIFEST" \ -images-manifest "$IMAGES_MANIFEST" \ | tee "${OUTPUT_DIR}/manifest.json" @@ -631,6 +963,132 @@ jobs: role-to-assume: arn:aws:iam::025044153841:role/GHA-Artifacts-${{ github.event.repository.owner.login }}-${{ github.event.repository.name }} aws-region: us-west-2 + - name: Create unified checksums file + working-directory: _workflows/_output + env: + BINARIES_CHECKSUMS: ${{ needs.goreleaser-binaries.outputs.binaries_checksums }} + WINDOWS_MANIFEST: ${{ needs.goreleaser-windows.outputs.windows_manifest }} + REPO_NAME: ${{ github.event.repository.name }} + VERSION: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + + # Determine checksums filename (matches GoReleaser default pattern) + VERSION_NO_V="${VERSION#v}" + CHECKSUMS_FILE="${REPO_NAME}_${VERSION_NO_V}_checksums.txt" + + # Start with binaries checksums + echo "Creating unified checksums file: $CHECKSUMS_FILE" + echo "$BINARIES_CHECKSUMS" > "./${CHECKSUMS_FILE}" + + # Append Windows asset hashes from manifest + # Format: + if [ -n "$WINDOWS_MANIFEST" ] && [ "$WINDOWS_MANIFEST" != "{}" ]; then + echo "Appending Windows hashes..." + echo "$WINDOWS_MANIFEST" | jq -r 'to_entries[] | "\(.value.sha256) \(.value.filename)"' >> "./${CHECKSUMS_FILE}" + fi + + echo "Unified checksums file:" + cat "./${CHECKSUMS_FILE}" + + # Sign the checksums file + echo "Signing checksums file..." + cosign sign-blob --yes "./${CHECKSUMS_FILE}" \ + --output-signature "./${CHECKSUMS_FILE}.sig" \ + --output-certificate "./${CHECKSUMS_FILE}.cert" + + # Generate provenance predicate + PREDICATE_FILE="checksums-predicate.json" + cat > "$PREDICATE_FILE" << EOF + { + "buildDefinition": { + "buildType": "https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml", + "externalParameters": { + "repository": "${{ github.repository }}", + "tag": "${VERSION}" + } + }, + "runDetails": { + "builder": { + "id": "https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@${{ needs.determine-workflows-ref.outputs.ref }}" + }, + "metadata": { + "invocationId": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + } + } + EOF + + # Generate provenance for checksums + cosign attest-blob \ + --yes \ + --predicate "$PREDICATE_FILE" \ + --type slsaprovenance1 \ + --bundle "./${CHECKSUMS_FILE}.provenance.sigstore.json" \ + "./${CHECKSUMS_FILE}" > /dev/null + echo "✅ Checksums signed with provenance" + + - name: Update manifest checksums hash + working-directory: _workflows/_output + env: + REPO_NAME: ${{ github.event.repository.name }} + VERSION: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + + # The unified checksums file may differ from the binaries-only version + # (when Windows hashes were appended). Update the manifest to match. + VERSION_NO_V="${VERSION#v}" + CHECKSUMS_FILE="${REPO_NAME}_${VERSION_NO_V}_checksums.txt" + + NEW_SHA=$(sha256sum "./${CHECKSUMS_FILE}" | awk '{print $1}') + NEW_SIZE=$(wc -c < "./${CHECKSUMS_FILE}" | tr -d ' ') + + echo "Updating manifest checksums hash: ${NEW_SHA} (${NEW_SIZE} bytes)" + jq --arg sha "$NEW_SHA" --argjson size "$NEW_SIZE" \ + 'if .assets.checksums then .assets.checksums.sha256 = $sha | .assets.checksums.sizeBytes = $size else . end' \ + manifest.json > manifest.tmp && mv manifest.tmp manifest.json + + - name: Re-sign manifest.json + working-directory: _workflows/_output + env: { COSIGN_EXPERIMENTAL: "1" } + shell: bash + run: | + set -euo pipefail + cosign sign-blob --yes "manifest.json" \ + --output-signature "manifest.json.sig" \ + --output-certificate "manifest.json.cert" + + - name: Upload checksums to S3 + working-directory: _workflows/_output + env: + BUCKET: ${{ env.S3_BUCKET }} + DIRECTORY: ${{ needs.goreleaser-binaries.outputs.s3_directory }} + REPO_NAME: ${{ github.event.repository.name }} + VERSION: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + + VERSION_NO_V="${VERSION#v}" + CHECKSUMS_FILE="${REPO_NAME}_${VERSION_NO_V}_checksums.txt" + + aws s3 cp "./${CHECKSUMS_FILE}" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}" \ + --cache-control "public,max-age=31536000,immutable" \ + --content-type "text/plain" + aws s3 cp "./${CHECKSUMS_FILE}.sig" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}.sig" \ + --cache-control "public,max-age=31536000,immutable" \ + --content-type "application/octet-stream" + aws s3 cp "./${CHECKSUMS_FILE}.cert" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}.cert" \ + --cache-control "public,max-age=31536000,immutable" \ + --content-type "application/x-pem-file" + aws s3 cp "./${CHECKSUMS_FILE}.provenance.sigstore.json" "s3://${BUCKET}/${DIRECTORY}/${CHECKSUMS_FILE}.provenance.sigstore.json" \ + --cache-control "public,max-age=31536000,immutable" \ + --content-type "application/json" + echo "✅ Checksums file and signatures uploaded" + - name: Upload manifest.json to S3 id: upload-manifest working-directory: _workflows/_output @@ -828,6 +1286,7 @@ jobs: [ determine-workflows-ref, goreleaser-binaries, + goreleaser-windows, goreleaser-docker, record-connector-registry, record-lambda-registry, diff --git a/README.md b/README.md index 926fcff..5511484 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ jobs: AC_PASSWORD: ${{ secrets.AC_PASSWORD }} AC_PROVIDER: ${{ secrets.AC_PROVIDER }} DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }} + GORELEASER_PRO_KEY: ${{ secrets.GORELEASER_PRO_KEY }} ``` The release workflow accepts the following input parameters: @@ -41,6 +42,8 @@ The release workflow accepts the following input parameters: | `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) | 2. Ensure your repository has the following secrets configured: @@ -50,6 +53,7 @@ The release workflow accepts the following input parameters: - `AC_PASSWORD`: Apple Connect password - `AC_PROVIDER`: Apple Connect provider - `DATADOG_API_KEY`: Datadog API key for monitoring releases + - `GORELEASER_PRO_KEY`: GoReleaser Pro license key (required when `msi: true`, the default) 3. Remove all GoReleaser, gon files, Dockerfile, and Dockerfile.lambda files from your connector repository, if they were previously created there. @@ -94,6 +98,45 @@ COPY ${TARGETPLATFORM}/${REPO_NAME} /${REPO_NAME} **Note:** Use `docker_extra_files` to include additional files or directories (comma-separated) in the Docker build context. These are paths relative to your connector repository root. +### Custom MSI Installers + +By default, the workflow builds a simple MSI installer that: +- Installs the binary to `C:\Program Files\ConductorOne\` +- Adds the installation directory to the system PATH + +For connectors that require custom MSI behavior (Windows Service, registry keys, etc.), provide a custom WXS template: + +```yaml +jobs: + release: + uses: ConductorOne/github-workflows/.github/workflows/release.yaml@v4 + with: + tag: ${{ github.ref_name }} + msi_wxs_path: ci/app.wxs + secrets: + # ... secrets ... +``` + +Your custom WXS template can use GoReleaser template variables: +- `{{ .ProjectName }}` - Connector name (e.g., "baton-okta") +- `{{ .Binary }}` - Binary name without extension +- `{{ .Version }}` - Full version string +- `{{ .Major }}`, `{{ .Minor }}`, `{{ .Patch }}` - Version components + +The `${UPGRADE_CODE}` placeholder is automatically replaced with a deterministic UUID v5 generated from the repository name, ensuring consistent upgrade behavior across versions. + +See [baton-runner/ci/app.wxs](https://github.com/ConductorOne/baton-runner/blob/main/ci/app.wxs) for an example Windows Service installer. + +To disable MSI builds entirely (e.g., for connectors that don't need Windows installers): + +```yaml + with: + tag: ${{ github.ref_name }} + msi: false +``` + +When `msi: false`, the `GORELEASER_PRO_KEY` secret is not required. + ## Available Actions ### Get Baton diff --git a/cmd/generate-windows-manifest/main.go b/cmd/generate-windows-manifest/main.go new file mode 100644 index 0000000..d17457c --- /dev/null +++ b/cmd/generate-windows-manifest/main.go @@ -0,0 +1,211 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + + pb "github.com/ConductorOne/github-workflows/pb/artifacts/v1" +) + +const ( + // AttestationTypeInTotoV1 is the in-toto Statement v1 envelope type + AttestationTypeInTotoV1 = "https://in-toto.io/Statement/v1" + // PredicateTypeSLSAProvenanceV1 is the SLSA v1 provenance predicate type + PredicateTypeSLSAProvenanceV1 = "https://slsa.dev/provenance/v1" + // PredicateTypeSPDX is the SPDX SBOM predicate type + PredicateTypeSPDX = "https://spdx.dev/Document" +) + +func main() { + var ( + distDir string + cdnBaseURL string + s3Dir string + ) + flag.StringVar(&distDir, "dist-dir", "", "Path to the dist directory containing Windows artifacts") + flag.StringVar(&cdnBaseURL, "cdn-base-url", "", "CDN base URL for artifact links") + flag.StringVar(&s3Dir, "s3-directory", "", "S3 directory path for artifacts") + flag.Parse() + + if distDir == "" || cdnBaseURL == "" || s3Dir == "" { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error: all flags are required\n") + fmt.Fprintf(os.Stderr, "Usage: generate-windows-manifest -dist-dir -cdn-base-url -s3-directory \n") + os.Exit(1) + } + + // Defense-in-depth: reject path traversal in S3 directory. + // The workflow's semver validation already prevents this, but validate here too. + if strings.Contains(s3Dir, "..") { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error: s3-directory must not contain '..': %s\n", s3Dir) + os.Exit(1) + } + + baseURL := fmt.Sprintf("%s/%s", cdnBaseURL, s3Dir) + assets := make(map[string]*pb.Asset) + + // Find and process zip files + zipFiles, err := filepath.Glob(filepath.Join(distDir, "*.zip")) + if err != nil { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding zip files: %v\n", err) + os.Exit(1) + } + + for _, zipPath := range zipFiles { + filename := filepath.Base(zipPath) + if strings.Contains(filename, "checksums") { + continue + } + + asset, err := buildAsset(zipPath, filename, "application/zip", baseURL, distDir) + if err != nil { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err) + os.Exit(1) + } + + // Windows zip uses key "windows-amd64" + assets["windows-amd64"] = asset + fmt.Fprintf(os.Stderr, "✅ Added zip asset: windows-amd64 -> %s\n", filename) + } + + // Find and process MSI files (flattened to dist root by workflow) + msiFiles, err := filepath.Glob(filepath.Join(distDir, "*.msi")) + if err != nil { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding MSI files: %v\n", err) + os.Exit(1) + } + + for _, msiPath := range msiFiles { + filename := filepath.Base(msiPath) + + asset, err := buildAsset(msiPath, filename, "application/x-msi", baseURL, distDir) + if err != nil { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err) + os.Exit(1) + } + + // MSI uses key "windows-amd64-msi" + // MSI has cosign signatures and attestations; Azure Trusted Signing (Windows code signing) planned for Stage 2 + assets["windows-amd64-msi"] = asset + fmt.Fprintf(os.Stderr, "✅ Added MSI asset: windows-amd64-msi -> %s\n", filename) + } + + // Marshal assets map to JSON + // We need to output a map[string]Asset JSON, not a full manifest + output := make(map[string]json.RawMessage) + marshalOpts := protojson.MarshalOptions{ + EmitUnpopulated: true, + } + + for key, asset := range assets { + jsonBytes, err := marshalOpts.Marshal(asset) + if err != nil { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling asset %s: %v\n", key, err) + os.Exit(1) + } + output[key] = jsonBytes + } + + // Output JSON to stdout + outputBytes, err := json.Marshal(output) + if err != nil { + fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling output: %v\n", err) + os.Exit(1) + } + + fmt.Println(string(outputBytes)) + fmt.Fprintf(os.Stderr, "✅ Generated Windows manifest with %d assets\n", len(assets)) +} + +func buildAsset(filePath, filename, mediaType, baseURL, distDir string) (*pb.Asset, error) { + // Calculate SHA256 + hash, err := sha256File(filePath) + if err != nil { + return nil, fmt.Errorf("calculating hash: %w", err) + } + + // Get file size + info, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("getting file info: %w", err) + } + + sizeBytes := info.Size() + href := fmt.Sprintf("%s/%s", baseURL, filename) + + // Check for signature and certificate files (all in dist root after flatten step) + var signatureHref, certificateHref *string + sigPath := filepath.Join(distDir, filename+".sig") + if _, err := os.Stat(sigPath); err == nil { + s := fmt.Sprintf("%s/%s.sig", baseURL, filename) + signatureHref = &s + } + certPath := filepath.Join(distDir, filename+".cert") + if _, err := os.Stat(certPath); err == nil { + c := fmt.Sprintf("%s/%s.cert", baseURL, filename) + certificateHref = &c + } + + // Build attestations array + var attestations []*pb.AttestationDescriptor + + // Check for provenance attestation + provenancePath := filepath.Join(distDir, filename+".provenance.sigstore.json") + if _, err := os.Stat(provenancePath); err == nil { + attestationType := AttestationTypeInTotoV1 + predicateType := PredicateTypeSLSAProvenanceV1 + bundleHref := fmt.Sprintf("%s/%s.provenance.sigstore.json", baseURL, filename) + attestations = append(attestations, pb.AttestationDescriptor_builder{ + AttestationType: &attestationType, + PredicateType: &predicateType, + BundleHref: &bundleHref, + }.Build()) + } + + // Check for SBOM attestation + sbomPath := filepath.Join(distDir, filename+".sbom.sigstore.json") + if _, err := os.Stat(sbomPath); err == nil { + attestationType := AttestationTypeInTotoV1 + predicateType := PredicateTypeSPDX + bundleHref := fmt.Sprintf("%s/%s.sbom.sigstore.json", baseURL, filename) + attestations = append(attestations, pb.AttestationDescriptor_builder{ + AttestationType: &attestationType, + PredicateType: &predicateType, + BundleHref: &bundleHref, + }.Build()) + } + + return pb.Asset_builder{ + Filename: &filename, + MediaType: &mediaType, + SizeBytes: &sizeBytes, + Sha256: &hash, + Href: &href, + SignatureHref: signatureHref, + CertificateHref: certificateHref, + Attestations: attestations, + }.Build(), nil +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/cmd/merge-manifests/main.go b/cmd/merge-manifests/main.go index 5023b6f..e711cce 100644 --- a/cmd/merge-manifests/main.go +++ b/cmd/merge-manifests/main.go @@ -22,9 +22,11 @@ func main() { var ( binariesManifest string imagesManifest string + windowsManifest string ) flag.StringVar(&binariesManifest, "binaries-manifest", "", "JSON string of binaries manifest") flag.StringVar(&imagesManifest, "images-manifest", "", "JSON string of images manifest (optional)") + flag.StringVar(&windowsManifest, "windows-manifest", "", "JSON string of Windows assets manifest (optional)") flag.Parse() if binariesManifest == "" { @@ -101,6 +103,41 @@ func main() { fmt.Fprintln(os.Stderr, "ℹ️ No images to add to manifest (docker job may have been skipped if no Dockerfile)") } + // Merge Windows assets if present + if windowsManifest != "" && windowsManifest != "{}" { + // Windows manifest format: { "windows-amd64": { "filename": "...", ... }, "windows-amd64-msi": { ... } } + var windowsMapJSON map[string]json.RawMessage + if err := json.Unmarshal([]byte(windowsManifest), &windowsMapJSON); err != nil { + fmt.Fprintf(os.Stderr, "merge-manifests: ::error::Invalid JSON in windows_manifest output\n") + fmt.Fprintf(os.Stderr, "merge-manifests: Raw content:\n%s\n", windowsManifest) + fmt.Fprintf(os.Stderr, "merge-manifests: Error: %v\n", err) + os.Exit(1) + } + + // Get or create assets map + assets := manifest.GetAssets() + if assets == nil { + assets = make(map[string]*pb.Asset) + manifest.SetAssets(assets) + } + + unmarshalOpts := protojson.UnmarshalOptions{ + DiscardUnknown: true, + } + for key, assetJSON := range windowsMapJSON { + asset := &pb.Asset{} + if err := unmarshalOpts.Unmarshal(assetJSON, asset); err != nil { + fmt.Fprintf(os.Stderr, "merge-manifests: error: unmarshaling Windows asset %s: %v\n", key, err) + os.Exit(1) + } + assets[key] = asset + } + + fmt.Fprintf(os.Stderr, "✅ Added %d Windows assets to manifest\n", len(windowsMapJSON)) + } else { + fmt.Fprintln(os.Stderr, "ℹ️ No Windows assets to add to manifest") + } + // Set manifest-level asset attestation descriptor if any assets have attestations hasAssetAttestations := false for _, asset := range manifest.GetAssets() { diff --git a/docs/diagrams/release-workflow.dot b/docs/diagrams/release-workflow.dot index 7c75875..8631dbb 100644 --- a/docs/diagrams/release-workflow.dot +++ b/docs/diagrams/release-workflow.dot @@ -17,7 +17,9 @@ digraph ReleaseWorkflow { determine_ref [label="determine-workflows-ref\n• resolve workflow SHA", fillcolor="#f9fafb"]; - binaries [label="goreleaser-binaries\n• build archives\n• gon codesign\n• SBOMs (syft)\n• provenance attestations\n• SBOM attestations\n• upload to S3", fillcolor="#ecfeff"]; + binaries [label="goreleaser-binaries\n• Linux + macOS archives\n• gon codesign (macOS)\n• SBOMs, provenance\n• upload to S3", fillcolor="#ecfeff"]; + + windows [label="goreleaser-windows\n• Windows zip + MSI\n• WiX Toolset\n• SBOMs, provenance\n• upload to S3", fillcolor="#ecfeff"]; docker [label="goreleaser-docker\n• multi-arch OCI images\n• Lambda image (arm64)\n• GHCR push\n• ECR Public push\n• image attestations", fillcolor="#ecfeff"]; @@ -38,11 +40,14 @@ digraph ReleaseWorkflow { tag -> validate; validate -> determine_ref; determine_ref -> binaries; + determine_ref -> windows; determine_ref -> docker; binaries -> record; + windows -> record; docker -> record; docker -> record_lambda; binaries -> s3 [label="artifacts"]; + windows -> s3 [label="artifacts"]; docker -> ghcr [label="push"]; docker -> ecr [label="push"]; record -> s3 [label="manifest"]; diff --git a/docs/diagrams/release-workflow.png b/docs/diagrams/release-workflow.png index ad16a1e..7401662 100644 Binary files a/docs/diagrams/release-workflow.png and b/docs/diagrams/release-workflow.png differ diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 5a4286d..19a1f14 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -8,12 +8,13 @@ The `release.yaml` workflow handles the complete release process for connector r When a tag is pushed to a connector repository, the shared release workflow: -1. Builds binaries for all platforms (macOS, Linux, Windows) -2. Builds multi-arch Docker images -3. Signs all artifacts with Sigstore (keyless) -4. Generates SLSA provenance attestations -5. Publishes to S3, GHCR, and ECR Public -6. Records the release in the connector registry +1. Builds binaries for macOS and Linux (with Apple codesigning) +2. Builds Windows zip and MSI installer (with WiX Toolset) +3. Builds multi-arch Docker images +4. Signs all artifacts with Sigstore (keyless) +5. Generates SLSA provenance attestations +6. Publishes to S3, GHCR, and ECR Public +7. Records the release in the connector registry ## Jobs @@ -24,6 +25,8 @@ Validates workflow inputs before proceeding: - Ensures tag is valid semver starting with 'v' (e.g., `v1.2.3`) - Ensures `dockerfile_template` is only used when `lambda: false` - Ensures `docker_extra_files` is only used when `dockerfile_template` is set +- Ensures `msi_wxs_path` has no path traversal (`..` or absolute paths) +- Ensures `GORELEASER_PRO_KEY` is provided when `msi: true` ### determine-workflows-ref @@ -31,16 +34,32 @@ Resolves the exact SHA of the shared workflow being used. This pinned reference ### goreleaser-binaries (macOS) -Builds and signs binary archives: +Builds and signs binary archives for macOS and Linux: -- Cross-compiles for darwin/linux/windows (amd64/arm64) -- Apple codesigning via gon +- Cross-compiles for darwin/linux (amd64/arm64) +- Apple codesigning via gon (macOS only) - Generates SBOMs using Syft - Creates SLSA v1 provenance attestations - Signs SBOMs as attestation bundles - Uploads all artifacts to S3 -**Outputs:** `*.zip`, `*.tar.gz`, `*.provenance.sigstore.json`, `*.sbom.sigstore.json` +**Outputs:** `*.zip` (macOS), `*.tar.gz` (Linux), `*.provenance.sigstore.json`, `*.sbom.sigstore.json` + +### goreleaser-windows (Windows) + +Builds Windows zip and MSI installer: + +- Native Windows build on Windows runner (not cross-compiled) +- Produces both `.zip` archive and `.msi` installer +- MSI built using WiX Toolset with GoReleaser Pro +- Deterministic UpgradeCode via UUID v5 from repository name +- Supports custom WXS templates via `msi_wxs_path` input +- Generates SBOMs and SLSA v1 provenance attestations +- Uploads all artifacts to S3 + +**Outputs:** `*.zip`, `*.msi`, `*.provenance.sigstore.json`, `*.sbom.sigstore.json` + +**Custom WXS:** For connectors requiring custom MSI behavior (Windows Service, registry keys, etc.), provide a custom WXS template via `msi_wxs_path`. If not provided, uses the default CLI installer template. ### goreleaser-docker (Linux) @@ -57,9 +76,10 @@ Builds and publishes container images: Finalizes the release: -- Merges binary and image manifests -- Signs `manifest.json` with Sigstore -- Uploads manifest to S3 +- Creates unified checksums file (all platforms) +- Merges binary, Windows, and image manifests +- Signs `manifest.json` and checksums with Sigstore +- Uploads manifest and checksums to S3 - Invokes release recording Lambda ### verify-release @@ -97,6 +117,23 @@ Software Bill of Materials (SPDX format) for each binary: - Signed as in-toto attestation - Links SBOM to the specific artifact +### Windows MSI Installers + +MSI installers are built using WiX Toolset with GoReleaser Pro: + +- **UpgradeCode:** Deterministic UUID v5 generated from repository name, ensuring consistent upgrade behavior across versions +- **Default template:** Installs to `C:\Program Files\ConductorOne\` and adds to PATH +- **Custom templates:** Connectors can provide custom WXS via `msi_wxs_path` input for Windows Service, registry keys, etc. + +The MSI uses a WiX-compatible version format (`x.x.x.0`) since WiX doesn't support semver prerelease suffixes. + +Both Windows zip and MSI have: +- Cosign signatures (`.sig`, `.cert`) +- SLSA provenance attestations +- SBOM attestations + +**Note:** Windows code signing via Azure Trusted Signing is planned for Stage 2. + ### Verification Anyone can verify artifacts using cosign: @@ -147,9 +184,29 @@ releases/{org}/{repo}/{tag}/ ├── manifest.json ├── manifest.json.sig ├── manifest.json.cert +├── baton-foo_1.0.0_checksums.txt +├── baton-foo_1.0.0_checksums.txt.sig +├── baton-foo_1.0.0_checksums.txt.cert ├── baton-foo-v1.0.0-darwin-arm64.zip +├── baton-foo-v1.0.0-darwin-arm64.zip.sig +├── baton-foo-v1.0.0-darwin-arm64.zip.cert ├── baton-foo-v1.0.0-darwin-arm64.zip.provenance.sigstore.json ├── baton-foo-v1.0.0-darwin-arm64.zip.sbom.sigstore.json +├── baton-foo-v1.0.0-linux-amd64.tar.gz +├── baton-foo-v1.0.0-linux-amd64.tar.gz.sig +├── baton-foo-v1.0.0-linux-amd64.tar.gz.cert +├── baton-foo-v1.0.0-linux-amd64.tar.gz.provenance.sigstore.json +├── baton-foo-v1.0.0-linux-amd64.tar.gz.sbom.sigstore.json +├── baton-foo-v1.0.0-windows-amd64.zip +├── baton-foo-v1.0.0-windows-amd64.zip.sig +├── baton-foo-v1.0.0-windows-amd64.zip.cert +├── baton-foo-v1.0.0-windows-amd64.zip.provenance.sigstore.json +├── baton-foo-v1.0.0-windows-amd64.zip.sbom.sigstore.json +├── baton-foo_v1.0.0_windows_amd64.msi +├── baton-foo_v1.0.0_windows_amd64.msi.sig +├── baton-foo_v1.0.0_windows_amd64.msi.cert +├── baton-foo_v1.0.0_windows_amd64.msi.provenance.sigstore.json +├── baton-foo_v1.0.0_windows_amd64.msi.sbom.sigstore.json └── ... ``` @@ -159,7 +216,8 @@ releases/{org}/{repo}/{tag}/ Use test connectors for validation: -- `ConductorOne/baton-github-test` - Full release testing +- `ConductorOne/baton-runner` - Custom WXS template testing (via `msi_wxs_path` at `ci/app.wxs`) +- `ConductorOne/baton-github-test` - Default WXS template testing ### Testing Process @@ -206,7 +264,58 @@ cosign verify-blob-attestation \ baton-github-test-v0.1.102-darwin-arm64.zip ``` +### MSI Installation Testing + +Test the MSI installer on an actual Windows machine: + +1. Download MSI from S3: + ```powershell + curl -LO "https://dist.conductorone.com/releases/ConductorOne/baton-runner/vX.Y.Z/baton-runner_vX.Y.Z_windows_amd64.msi" + ``` + +2. Install and verify: + - Double-click MSI or run `msiexec /i baton-runner_vX.Y.Z_windows_amd64.msi` + - Check installation path: `C:\Program Files\ConductorOne\baton-runner\` + - Verify PATH includes installation directory + - Run `baton-runner --help` from new terminal + +3. Test upgrade scenario: + - Install older version first + - Install newer version over it + - Verify clean upgrade (no duplicate entries, old version removed) + +4. Test uninstall: + - Uninstall via Settings > Apps or `msiexec /x` + - Verify files removed from Program Files + - Verify PATH entry removed + +5. Verify UpgradeCode consistency: + - UpgradeCode is deterministic UUID v5 from repository name + - Must be identical across all versions for upgrades to work + ### Common Issues - **Cosign version:** Ensure using latest cosign (`cosign version`) - **GHCR access:** May need `docker login ghcr.io` for image verification +- **MSI UpgradeCode:** If upgrades don't work, verify UpgradeCode is consistent across versions + +## Future Work + +### Stage 2: Windows Code Signing + +Currently MSI installers have Sigstore signatures (cosign) but not Windows Authenticode signatures. Stage 2 will add: + +- **Azure Trusted Signing** integration for Authenticode signatures +- MSI files will be signed with Microsoft-trusted certificate +- Windows SmartScreen warnings will be eliminated +- Users can verify publisher identity in Windows UAC prompts + +This requires: +- Azure Trusted Signing account setup +- GitHub Actions OIDC integration with Azure +- Workflow updates to sign MSI after build + +### Other Potential Improvements + +- Support for Windows ARM64 builds +- Windows Service installer templates (for connectors that run as services) diff --git a/scripts/validate-release-artifacts.sh b/scripts/validate-release-artifacts.sh index 62ba6b3..3d0ff4a 100755 --- a/scripts/validate-release-artifacts.sh +++ b/scripts/validate-release-artifacts.sh @@ -124,46 +124,54 @@ for platform in $(echo "$MANIFEST" | jq -r '.assets | keys[]'); do continue fi - # Check binary signature (.sig + .cert files) - SIG_FILE="${HREF}.sig" - CERT_FILE="${HREF}.cert" - if curl -sfL "$SIG_FILE" -o "$TEMP_DIR/${FILENAME}.sig" 2>/dev/null && \ - curl -sfL "$CERT_FILE" -o "$TEMP_DIR/${FILENAME}.cert" 2>/dev/null; then - if cosign verify-blob \ - --signature "$TEMP_DIR/${FILENAME}.sig" \ - --certificate "$TEMP_DIR/${FILENAME}.cert" \ - --certificate-oidc-issuer "$CERT_OIDC_ISSUER" \ - --certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \ - "$TEMP_DIR/$FILENAME" > /dev/null 2>&1; then - pass "Binary signature verified: $platform" + # Check binary signature (.sig + .cert files) - skip for MSI (derived artifact) + if [[ "$platform" == *-msi ]]; then + info "Skipping .sig/.cert check for $platform (derived artifact)" + else + SIG_FILE="${HREF}.sig" + CERT_FILE="${HREF}.cert" + if curl -sfL "$SIG_FILE" -o "$TEMP_DIR/${FILENAME}.sig" 2>/dev/null && \ + curl -sfL "$CERT_FILE" -o "$TEMP_DIR/${FILENAME}.cert" 2>/dev/null; then + if cosign verify-blob \ + --signature "$TEMP_DIR/${FILENAME}.sig" \ + --certificate "$TEMP_DIR/${FILENAME}.cert" \ + --certificate-oidc-issuer "$CERT_OIDC_ISSUER" \ + --certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \ + "$TEMP_DIR/$FILENAME" > /dev/null 2>&1; then + pass "Binary signature verified: $platform" + else + fail "Binary signature verification failed: $platform" + fi else - fail "Binary signature verification failed: $platform" + fail "Binary signature files missing: $platform (.sig or .cert)" fi - else - fail "Binary signature files missing: $platform (.sig or .cert)" fi - - # Check provenance attestation - PROV_BUNDLE="${HREF}.provenance.sigstore.json" - if ! curl -sfL "$PROV_BUNDLE" -o "$TEMP_DIR/${FILENAME}.provenance.sigstore.json" 2>/dev/null; then - fail "Provenance bundle missing: $PROV_BUNDLE" + + # Check provenance attestation (skip for MSI - it's derived from the same binary as the zip) + if [[ "$platform" == *-msi ]]; then + info "Skipping provenance check for $platform (derived from zip)" else - # Verify provenance - if cosign verify-blob-attestation \ - --bundle "$TEMP_DIR/${FILENAME}.provenance.sigstore.json" \ - --type https://slsa.dev/provenance/v1 \ - --certificate-oidc-issuer "$CERT_OIDC_ISSUER" \ - --certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \ - "$TEMP_DIR/$FILENAME" > /dev/null 2>&1; then - pass "Provenance verified: $platform" + PROV_BUNDLE="${HREF}.provenance.sigstore.json" + if ! curl -sfL "$PROV_BUNDLE" -o "$TEMP_DIR/${FILENAME}.provenance.sigstore.json" 2>/dev/null; then + fail "Provenance bundle missing: $PROV_BUNDLE" else - fail "Provenance verification failed: $platform" + # Verify provenance + if cosign verify-blob-attestation \ + --bundle "$TEMP_DIR/${FILENAME}.provenance.sigstore.json" \ + --type https://slsa.dev/provenance/v1 \ + --certificate-oidc-issuer "$CERT_OIDC_ISSUER" \ + --certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \ + "$TEMP_DIR/$FILENAME" > /dev/null 2>&1; then + pass "Provenance verified: $platform" + else + fail "Provenance verification failed: $platform" + fi fi fi - - # Check SBOM attestation (skip for checksums - only binary archives have SBOMs) - if [[ "$platform" == "checksums" ]]; then - info "Skipping SBOM check for checksums (not applicable)" + + # Check SBOM attestation (skip for checksums and MSI - only binary archives have SBOMs) + if [[ "$platform" == "checksums" || "$platform" == *-msi ]]; then + info "Skipping SBOM check for $platform (not applicable)" else SBOM_BUNDLE="${HREF}.sbom.sigstore.json" if ! curl -sfL "$SBOM_BUNDLE" -o "$TEMP_DIR/${FILENAME}.sbom.sigstore.json" 2>/dev/null; then diff --git a/templates/.goreleaser-binaries-template.yaml.tmpl b/templates/.goreleaser-binaries-template.yaml.tmpl index 63fda6a..0664a9e 100644 --- a/templates/.goreleaser-binaries-template.yaml.tmpl +++ b/templates/.goreleaser-binaries-template.yaml.tmpl @@ -12,15 +12,7 @@ builds: goarch: - amd64 - arm64 - - binary: "${REPO_NAME}" - env: - - CGO_ENABLED=0 - id: windows - main: ./cmd/${REPO_NAME} - goos: - - windows - goarch: - - amd64 + # Note: Windows builds moved to dedicated goreleaser-windows job for MSI support - binary: "${REPO_NAME}" env: - CGO_ENABLED=0 @@ -53,13 +45,7 @@ archives: name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}" files: - none* - - id: windows-archive - builds: - - windows - format: zip - name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}" - files: - - none* + # Note: Windows archive moved to dedicated goreleaser-windows job - id: darwin-archive builds: - macos-amd64 @@ -71,14 +57,14 @@ archives: release: ids: - linux-archive - - windows-archive - darwin-archive snapshot: version_template: "{{ incpatch .Version }}-dev" checksum: + # Note: checksums are NOT uploaded here - they're merged with Windows hashes + # and uploaded by the record-connector-registry job ids: - linux-archive - - windows-archive - darwin-archive sboms: - artifacts: archive @@ -89,7 +75,6 @@ signs: artifacts: archive ids: - linux-archive - - windows-archive - darwin-archive certificate: "{{ .Env.artifact }}.cert" args: @@ -100,19 +85,8 @@ signs: - "{{ .Env.artifact }}" env: - COSIGN_EXPERIMENTAL=1 - - id: cosign-checksums - output: true - cmd: cosign - artifacts: checksum - certificate: "{{ .Env.artifact }}.cert" - args: - - "sign-blob" - - "--yes" - - "--output-signature={{ .Env.signature }}" - - "--output-certificate={{ .Env.certificate }}" - - "{{ .Env.artifact }}" - env: - - COSIGN_EXPERIMENTAL=1 + # Note: checksums signing moved to record-connector-registry job + # to allow merging with Windows hashes first brews: - repository: owner: conductorone @@ -137,9 +111,9 @@ blobs: directory: "${S3_DIRECTORY}" ids: - linux-archive - - windows-archive - darwin-archive - - checksum + # Note: checksum NOT included - merged with Windows and uploaded by registry job + # Note: Windows archives uploaded by dedicated goreleaser-windows job extra_files: - glob: dist/*.sig - glob: dist/*.cert diff --git a/templates/.goreleaser-windows-template.yaml.tmpl b/templates/.goreleaser-windows-template.yaml.tmpl new file mode 100644 index 0000000..5f3aad8 --- /dev/null +++ b/templates/.goreleaser-windows-template.yaml.tmpl @@ -0,0 +1,75 @@ +## Windows template for zip and MSI installers (requires GoReleaser Pro for MSI) +## This runs on Windows runner with Wix Toolset +version: 2 +project_name: "${REPO_NAME}" +builds: + - binary: "${REPO_NAME}" + env: + - CGO_ENABLED=0 + id: windows + main: ./cmd/${REPO_NAME} + goos: + - windows + goarch: + - amd64 +archives: + - id: windows-archive + builds: + - windows + format: zip + name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}" + files: + - none* +msi: + - id: windows-msi + ids: + - windows + wxs: ${WXS_PATH} + mod_timestamp: "{{ .CommitTimestamp }}" + name: "{{ .ProjectName }}_v{{ .Version }}_windows_amd64" +release: + disable: true +checksum: + disable: true +sboms: + - id: sbom-archive + artifacts: archive + ids: + - windows-archive + - id: sbom-msi + artifacts: installer + ids: + - windows-msi +signs: + - id: cosign-archives + output: true + cmd: cosign + artifacts: archive + ids: + - windows-archive + certificate: "{{ .Env.artifact }}.cert" + args: + - "sign-blob" + - "--yes" + - "--output-signature={{ .Env.signature }}" + - "--output-certificate={{ .Env.certificate }}" + - "{{ .Env.artifact }}" + env: + - COSIGN_EXPERIMENTAL=1 + - id: cosign-msi + output: true + cmd: cosign + artifacts: installer + ids: + - windows-msi + certificate: "{{ .Env.artifact }}.cert" + args: + - "sign-blob" + - "--yes" + - "--output-signature={{ .Env.signature }}" + - "--output-certificate={{ .Env.certificate }}" + - "{{ .Env.artifact }}" + env: + - COSIGN_EXPERIMENTAL=1 +changelog: + disable: true diff --git a/templates/.wxs-default-template.wxs b/templates/.wxs-default-template.wxs new file mode 100644 index 0000000..3abcadd --- /dev/null +++ b/templates/.wxs-default-template.wxs @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +