diff --git a/.github/actions/setup-release-cli/action.yml b/.github/actions/setup-release-cli/action.yml new file mode 100644 index 0000000..8682564 --- /dev/null +++ b/.github/actions/setup-release-cli/action.yml @@ -0,0 +1,153 @@ +name: Set up release-cli +description: > + Acquires release-cli either from the release stamped into this file + (supported path) or from a caller-supplied binary (unsupported escape + hatch), then enforces the version/protocol guard. + +inputs: + cli-path: + description: > + Unsupported path to a caller-supplied release-cli binary. You supply + the binary, you own the pairing: a stamp mismatch warns instead of + failing. + required: false + default: '' + +outputs: + cli-path: + description: Absolute executable path selected by the action. + value: ${{ steps.resolve.outputs.cli-path }} + reported-version: + description: Version the binary reported. + value: ${{ steps.resolve.outputs.reported-version }} + reported-protocol: + description: Protocol integer the binary reported. + value: ${{ steps.resolve.outputs.reported-protocol }} + +runs: + using: composite + steps: + - name: Resolve and verify the CLI + id: resolve + shell: bash + env: + # Release Please keeps this value in step with the released binary. + # x-release-please-start-version + DEFAULT_VERSION: 0.0.0 + # x-release-please-end + EXPECTED_PROTOCOL: 1 + CLI_PATH_INPUT: ${{ inputs.cli-path }} + SOURCE_REPOSITORY: ${{ github.action_repository }} + GH_TOKEN: ${{ inputs.cli-path == '' && github.token || '' }} + run: | + set -euo pipefail + + if [[ -n "${CLI_PATH_INPUT}" ]]; then + # Caller-supplied binary: no download, no attestation, no token. + unset GH_TOKEN + mode=cli-path + binary="$(cd "$(dirname "${CLI_PATH_INPUT}")" && pwd)/$(basename "${CLI_PATH_INPUT}")" + test -f "${binary}" + test -x "${binary}" + else + mode=installed + + # Download source and attestation trust root are the same value. + # Never fall back to GITHUB_REPOSITORY: that is the consumer on + # an external call, and a missing action_repository would verify + # an archive against the consumer itself. + if [[ -z "${SOURCE_REPOSITORY:-}" ]]; then + echo '::error::Cannot derive the release-cli distribution repository. Reference this action as $/.github/actions/setup-release-cli from a reusable workflow, or as owner/repo/path@ref. A local ./.github/actions path leaves github.action_repository empty.' + exit 1 + fi + repository="${SOURCE_REPOSITORY}" + + if ! command -v gh >/dev/null 2>&1; then + echo '::error::gh is required to download and verify release-cli. Install the GitHub CLI and retry.' + exit 1 + fi + if ! gh attestation --help >/dev/null 2>&1; then + echo '::error::gh attestation is unavailable. Upgrade the GitHub CLI and retry.' + exit 1 + fi + + workdir="$(mktemp -d)" + tag="v${DEFAULT_VERSION}" + + gh release download "${tag}" \ + --repo "${repository}" \ + --pattern 'release-cli_*_linux_amd64.tar.gz' \ + --pattern 'checksums.txt' \ + --dir "${workdir}" + + shopt -s nullglob + archives=("${workdir}"/release-cli_*_linux_amd64.tar.gz) + if [[ ${#archives[@]} -ne 1 ]]; then + echo "::error::Expected exactly one release-cli_*_linux_amd64.tar.gz archive, found ${#archives[@]}." + exit 1 + fi + archive="${archives[0]}" + archive_name="$(basename "${archive}")" + + mapfile -t checksum_lines < <( + awk -v name="${archive_name}" ' + $1 ~ /^[0-9A-Fa-f]{64}$/ { + n = $0 + sub(/^[0-9A-Fa-f]{64} [ *]/, "", n) + if (n == name) print + } + ' "${workdir}/checksums.txt" + ) + if [[ ${#checksum_lines[@]} -ne 1 ]]; then + echo "::error::checksums.txt must contain exactly one SHA-256 entry for ${archive_name}." + exit 1 + fi + ( + cd "${workdir}" + printf '%s\n' "${checksum_lines[0]}" > .archive.sha256 + sha256sum -c .archive.sha256 + ) + + gh attestation verify "${archive}" \ + --repo "${repository}" \ + --signer-workflow "${repository}/.github/workflows/publish-github-release.yml" \ + --deny-self-hosted-runners + + tar -xzf "${archive}" -C "${workdir}" + binary="${workdir}/release-cli" + test -f "${binary}" + chmod +x "${binary}" + fi + + report="$("${binary}" version --json)" + reported_version="$(printf '%s' "${report}" | jq -r .result.version)" + reported_protocol="$(printf '%s' "${report}" | jq -r .result.protocol)" + + { + echo "cli-path=${binary}" + echo "reported-version=${reported_version}" + echo "reported-protocol=${reported_protocol}" + } >>"${GITHUB_OUTPUT}" + + echo "mode=${mode} version=${reported_version} protocol=${reported_protocol}" + + if [[ "${mode}" == "installed" ]]; then + # Supported path: fail closed before any command runs. + if [[ "${reported_version}" != "${DEFAULT_VERSION}" ]]; then + echo "::error::Installed CLI reported version ${reported_version}, expected ${DEFAULT_VERSION}." + exit 1 + fi + if [[ "${reported_protocol}" != "${EXPECTED_PROTOCOL}" ]]; then + echo "::error::Installed CLI reported protocol ${reported_protocol}, expected ${EXPECTED_PROTOCOL}." + exit 1 + fi + echo "installed path verified: version and protocol match the release unit" + else + # Unsupported path: report, warn, and continue. + if [[ "${reported_protocol}" != "${EXPECTED_PROTOCOL}" ]]; then + echo "::warning::Off-contract binary: protocol ${reported_protocol} != expected ${EXPECTED_PROTOCOL}. You supplied the binary, you own the pairing." + fi + if [[ "${reported_version}" != "${DEFAULT_VERSION}" ]]; then + echo "::warning::Off-contract binary: version ${reported_version} != release-unit version ${DEFAULT_VERSION}." + fi + fi diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 054ef8f..14c0150 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -2,6 +2,14 @@ name: Reusable Go Pre-publish on: workflow_call: + inputs: + cli-path: + description: > + Unsupported path to a caller-supplied release-cli binary. You + supply the binary, you own the pairing. + required: false + type: string + default: '' outputs: artifact-id: description: ID of the authoritative release-assets artifact. @@ -37,6 +45,8 @@ jobs: oci-input-artifact-url: ${{ steps.upload-oci-input.outputs.artifact-url }} oci-input-artifact-digest: ${{ steps.upload-oci-input.outputs.artifact-digest }} permissions: + actions: read + attestations: read contents: read id-token: write env: @@ -85,37 +95,35 @@ jobs: goreleaser release --clean --skip=publish ' - - name: Verify release asset checksums - working-directory: dist + - name: Download dogfood release-cli + if: inputs.cli-path != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-cli-dogfood + path: ${{ runner.temp }}/release-cli-dogfood + + - name: Place dogfood release-cli + if: inputs.cli-path != '' + env: + CLI_PATH: ${{ inputs.cli-path }} shell: bash run: | - sha256sum --check checksums.txt - test -s checksums.txt.sigstore.json + set -euo pipefail + mkdir -p "$(dirname "${CLI_PATH}")" + install -m755 "${RUNNER_TEMP}/release-cli-dogfood/release-cli" "${CLI_PATH}" + + - name: Set up release-cli + id: setup-cli + uses: $/.github/actions/setup-release-cli + with: + cli-path: ${{ inputs.cli-path }} - - name: Verify canonical Linux binaries + - name: Stage Go release artifacts + env: + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} shell: bash run: | - mapfile -t binaries < <( - jq -r ' - .[] - | select(.type == "Binary" and .goos == "linux") - | [.goarch, .path] - | @tsv - ' dist/artifacts.json - ) - if [[ ${#binaries[@]} -ne 2 ]]; then - echo "::error::Expected two canonical Linux binaries; found ${#binaries[@]}." - exit 1 - fi - printf '%s\n' "${binaries[@]}" | - cut -f1 | - sort | - diff -u <(printf 'amd64\narm64\n') - - - for record in "${binaries[@]}"; do - path=${record#*$'\t'} - test -x "${path}" - done + "${RELEASE_CLI}" stage --profile go --dist dist - name: Upload canonical Linux binaries id: upload-oci-input diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b33dc7..87eebd2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,13 +12,64 @@ concurrency: cancel-in-progress: false jobs: + build-release-cli: + name: Build dogfood release-cli + if: github.event.deleted == false + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + GOTOOLCHAIN: local + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: go + cache: true + add_shims_to_path: false + export_path: false + + - name: Build release-cli + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + mkdir -p "${RUNNER_TEMP}/release-cli-dogfood" + mise exec -- go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${version} -X main.commit=${GITHUB_SHA}" \ + -o "${RUNNER_TEMP}/release-cli-dogfood/release-cli" \ + ./cmd/release-cli + + - name: Upload dogfood release-cli + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-cli-dogfood + path: ${{ runner.temp }}/release-cli-dogfood/release-cli + if-no-files-found: error + retention-days: 1 + compression-level: 0 + release-assets: name: Build release assets if: github.event.deleted == false + needs: build-release-cli permissions: + actions: read + attestations: read contents: read id-token: write uses: ./.github/workflows/go-pre-publish.yml + with: + cli-path: .release-cli/release-cli oci-image: name: Build OCI image needs: release-assets diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2c9023e..212044e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 -project_name: release-mvp +project_name: release-cli gomod: proxy: true @@ -11,9 +11,9 @@ gomod: - GOSUMDB=sum.golang.org builds: - - id: release-mvp - main: ./cmd/release-mvp - binary: release-mvp + - id: release-cli + main: ./cmd/release-cli + binary: release-cli env: - CGO_ENABLED=0 goos: @@ -32,9 +32,9 @@ builds: mod_timestamp: "{{ .CommitTimestamp }}" archives: - - id: release-mvp + - id: release-cli ids: - - release-mvp + - release-cli formats: - tar.gz format_overrides: diff --git a/README.md b/README.md index 2d5ee9e..38017b7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Meigma release workflows -This repository defines the reusable workflows and repository contract that Meigma uses to build, sign, attest, and publish Go binaries through GitHub Releases and multi-architecture OCI images through GHCR. +This repository defines the reusable workflows and repository contract that Meigma uses to build, sign, attest, and publish Go binaries through GitHub Releases and multi-architecture OCI images through GHCR. It also builds and publishes `release-cli`, which the Go producer uses to validate staged release artifacts. The repository's tagged release builds the CLI from its own source and supplies that binary to the producer workflow. ## Documentation @@ -8,8 +8,9 @@ This repository defines the reusable workflows and repository contract that Meig - [Configure OCI image publication](docs/how-to/configure-oci-images.md) - [Rehearse and recover GitHub releases](docs/how-to/rehearse-and-recover-github-releases.md) - [Upgrade GitHub release workflows](docs/how-to/upgrade-github-release-workflows.md) +- [`release-cli` contract reference](docs/reference/release-cli-contract.md) - [GitHub release contract reference](docs/reference/github-release-contract.md) - [OCI image contract reference](docs/reference/oci-image-contract.md) - [Copyable Go release example](examples/go-release/) -Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `fb8c8098ff27968fb3070e928c00e925f38c698e`. +Consumer repositories call the reusable workflows at one full commit SHA. `FULL_SHA` is the placeholder for the released commit and will be replaced when this program's final pull request lands; the copyable example remains pinned to the last released revision until then. diff --git a/apko.yaml b/apko.yaml index 4febe96..c6bf887 100644 --- a/apko.yaml +++ b/apko.yaml @@ -4,10 +4,10 @@ contents: packages: - alpine-release - ca-certificates-bundle - - release-mvp + - release-cli entrypoint: - command: /usr/bin/release-mvp + command: /usr/bin/release-cli accounts: groups: @@ -27,7 +27,7 @@ archs: - arm64 annotations: - org.opencontainers.image.title: release-mvp + org.opencontainers.image.title: release-cli org.opencontainers.image.description: Exercise the Meigma release pipeline. org.opencontainers.image.source: https://github.com/meigma/release org.opencontainers.image.licenses: LicenseRef-Proprietary diff --git a/cmd/release-cli/doc.go b/cmd/release-cli/doc.go new file mode 100644 index 0000000..1e3dc58 --- /dev/null +++ b/cmd/release-cli/doc.go @@ -0,0 +1,5 @@ +// Package main is the release-cli process entrypoint. +// +// It installs a signal-aware [context.Context], injects real process streams +// and linker-stamped version metadata, and exits with [cli.ExitCode]. +package main diff --git a/cmd/release-mvp/main.go b/cmd/release-cli/main.go similarity index 75% rename from cmd/release-mvp/main.go rename to cmd/release-cli/main.go index b128a0c..5bd6940 100644 --- a/cmd/release-mvp/main.go +++ b/cmd/release-cli/main.go @@ -16,10 +16,12 @@ var ( commit = "none" ) +// main is the process entrypoint. func main() { os.Exit(run()) } +// run constructs the command tree and returns the process exit code. func run() int { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -29,13 +31,14 @@ func run() int { Out: os.Stdout, Err: os.Stderr, Build: cli.BuildInfo{ - Version: version, - Commit: commit, + Version: version, + Commit: commit, + Protocol: cli.Protocol, }, }) if err := root.ExecuteContext(ctx); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) - return 1 + return cli.ExitCode(err) } return 0 diff --git a/docs/how-to/configure-github-releases.md b/docs/how-to/configure-github-releases.md index 552c70b..01edcf8 100644 --- a/docs/how-to/configure-github-releases.md +++ b/docs/how-to/configure-github-releases.md @@ -2,6 +2,9 @@ Use this guide to add the shared Meigma Go release workflows to a repository. The [GitHub Release contract](../reference/github-release-contract.md) defines the reusable workflow inputs, permissions, artifacts, and failure behavior. +`FULL_SHA` is the placeholder for the released commit and will be replaced when +this program's final pull request lands. + ## Prerequisites Before you change the repository, confirm that: @@ -100,8 +103,8 @@ In the copied files, replace the example values with values from the consumer re Do not replace these shared contract values: -- reusable workflow revision `fb8c8098ff27968fb3070e928c00e925f38c698e`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e`; +- reusable workflow revision `FULL_SHA`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA`; - variable name `MEIGMA_RELEASE_APP_CLIENT_ID`; or - secret name `MEIGMA_RELEASE_APP_PRIVATE_KEY`. @@ -109,6 +112,12 @@ To change the immutable revision later, follow [Upgrade GitHub Release workflows The copied GoReleaser configuration builds Darwin, Linux, and Windows archives for amd64 and arm64. Confirm that the consumer command supports those targets before releasing it. +The producer runs GoReleaser, obtains `release-cli` through the shared setup +action, and then runs `release-cli stage --profile go --dist dist` before +uploading either Actions artifact. Leave the optional `cli-path` input unset in +a consumer repository. It is an unsupported escape hatch for this repository's +dogfood release and for callers that own the workflow-to-binary pairing. + The copied release caller sets both `publish-image: false` and `publish-release: false`. Keep both values for the first rehearsal. Before a public release, change both to `true` and merge the change before Release Please creates the tag. The [rehearsal and recovery guide](rehearse-and-recover-github-releases.md) gives the safer first-run sequence. ## 5. Generate and validate the tool lock @@ -227,7 +236,7 @@ Verify that the checksum manifest was signed by the canonical reusable pre-publi ```bash mise exec -- cosign verify-blob \ --bundle checksums.txt.sigstore.json \ - --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e' \ + --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ checksums.txt ``` @@ -243,7 +252,7 @@ while IFS= read -r entry; do mise exec -- gh attestation verify "$asset" \ --repo "$REPOSITORY" \ --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ - --signer-digest fb8c8098ff27968fb3070e928c00e925f38c698e \ + --signer-digest FULL_SHA \ --source-ref "refs/tags/$TAG" \ --deny-self-hosted-runners done < checksums.txt diff --git a/docs/how-to/rehearse-and-recover-github-releases.md b/docs/how-to/rehearse-and-recover-github-releases.md index 7a3e015..4c189dc 100644 --- a/docs/how-to/rehearse-and-recover-github-releases.md +++ b/docs/how-to/rehearse-and-recover-github-releases.md @@ -2,6 +2,9 @@ Use this guide to populate a draft GitHub Release without publishing it, then resume publication through the same tag and draft. Complete [Configure GitHub Releases](configure-github-releases.md) first. The [GitHub Release contract](../reference/github-release-contract.md) defines the checks that each run enforces. +`FULL_SHA` is the placeholder for the released commit and will be replaced when +this program's final pull request lands. + ## Prerequisites Before starting a rehearsal, confirm that: @@ -248,6 +251,20 @@ Keep the release as a draft while diagnosing any failure below. If recovery changes source, workflow configuration, or tool pins, merge that correction, record its commit SHA, and trigger a new run by authorized movement of the unpublished tag to that commit. Then select the run by the exact tag and SHA as shown above. If the tag cannot be moved safely, abandon the incomplete candidate and cut a new one. When repository content is unchanged and upstream build jobs succeeded, rerun only failed jobs with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed`; this preserves the authoritative artifacts from the original workflow run. Use a complete rerun only when an upstream artifact must be rebuilt, such as artifact expiry or an artifact-handoff failure. +### Release artifact staging fails + +The producer runs `release-cli stage --profile go --dist dist` after GoReleaser +and before either Actions artifact upload. It stops on an invalid checksum +claim or bundle, an invalid Linux binary selection, an escaped path, or a binary +that is not a regular executable file. The failed step writes the offending +artifact diagnostic to stderr. + +Use the diagnostic and the [`release-cli` contract](../reference/release-cli-contract.md) +to inspect the generated `dist` files. Correct the source or GoReleaser +configuration instead of bypassing the check. If the correction changes +repository content, merge it and move the unpublished rehearsal tag to the new +commit as described above, or abandon the candidate and cut a new one. + ### The matching draft is missing The publisher polls the releases collection for the current tag and then reports `No GitHub Release found` if none appears. @@ -287,7 +304,7 @@ If the repository does not need a correction, rerun the complete top-level workf For the documented current revision, the publisher requires a nonempty `checksums.txt`, the exact closed payload list, matching payload hashes, a regular Cosign bundle file, issuer `https://token.actions.githubusercontent.com`, and this certificate identity: ```text -https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e +https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA ``` For an upgrade rehearsal, replace the current-revision identity with the target value described in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). diff --git a/docs/how-to/upgrade-github-release-workflows.md b/docs/how-to/upgrade-github-release-workflows.md index 6b3ca2c..b5a0480 100644 --- a/docs/how-to/upgrade-github-release-workflows.md +++ b/docs/how-to/upgrade-github-release-workflows.md @@ -1,6 +1,6 @@ # Upgrade GitHub Release workflows -Use this guide to move a consumer repository from the current workflow revision, `fb8c8098ff27968fb3070e928c00e925f38c698e`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) and [OCI image contract](../reference/oci-image-contract.md) define the current interfaces and publication boundaries. +Use this guide to move a consumer repository from its current workflow revision to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md), [OCI image contract](../reference/oci-image-contract.md), and [`release-cli` contract](../reference/release-cli-contract.md) define the current interfaces and publication boundaries. ## Prerequisites @@ -16,11 +16,12 @@ Record the consumer, the current baseline, and a local checkout of `meigma/relea ```bash export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export CURRENT_RELEASE_REVISION=fb8c8098ff27968fb3070e928c00e925f38c698e +read -r -p 'Current full meigma/release commit SHA: ' CURRENT_RELEASE_REVISION read -r -p 'Reviewed full meigma/release commit SHA: ' NEW_RELEASE_REVISION export NEW_RELEASE_REVISION read -r -p 'Path to the meigma/release checkout: ' RELEASE_CHECKOUT export RELEASE_CHECKOUT +[[ "$CURRENT_RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] [[ "$NEW_RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] test "$NEW_RELEASE_REVISION" != "$CURRENT_RELEASE_REVISION" test "$(gh api "repos/meigma/release/commits/$NEW_RELEASE_REVISION" --jq .sha)" = \ @@ -52,9 +53,11 @@ git -C "$RELEASE_CHECKOUT" diff \ .github/workflows/publish-github-release.yml \ .github/workflows/publish-oci-image.yml \ .github/workflows/release.yml \ + .github/actions/setup-release-cli/action.yml \ .github/workflows/release-please.yml \ docs/reference/github-release-contract.md \ docs/reference/oci-image-contract.md \ + docs/reference/release-cli-contract.md \ examples/go-release ``` @@ -70,6 +73,8 @@ git -C "$RELEASE_CHECKOUT" show \ Before adoption, identify changes to: - reusable workflow inputs, outputs, secrets, and caller permissions; +- the `release-cli` commands, flags, exit codes, and result fields used by the workflows; +- the setup action's acquisition behavior and version and protocol checks; - checksum signer and attestation identities; - artifact handoff, payload names, SBOMs, checksums, and publication states; - consumer source and GoReleaser configuration requirements; @@ -91,6 +96,12 @@ In `.github/workflows/release.yml`, replace the current revision with `NEW_RELEA Keep both `publish-image: false` and `publish-release: false` for the upgrade rehearsal. All four reusable workflow references and the checksum signing identity must change in the same pull request and commit. A mixed revision fails a signing boundary or runs contracts that were not reviewed together. +The one full commit SHA selects the workflows, their composite setup action, and +the `release-cli` version used by those workflows. Do not add a separate CLI +version setting. Leave the optional `cli-path` input unset in a normal consumer. +It is an unsupported escape hatch for this repository's dogfood release and for +callers that own the workflow-to-binary pairing. + Apply every other target-contract change in that same upgrade: - update caller permissions, inputs, output consumption, and secrets; diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md index 0ab0334..30a0ed1 100644 --- a/docs/reference/github-release-contract.md +++ b/docs/reference/github-release-contract.md @@ -1,25 +1,25 @@ # GitHub release contract reference -This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `fb8c8098ff27968fb3070e928c00e925f38c698e`. +This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `FULL_SHA`. The placeholder will be replaced with the released commit when this program's final pull request lands. -For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). The [OCI image contract](oci-image-contract.md) defines the image builder and publisher that gate the complete delivery caller. To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). +For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). The [`release-cli` contract](release-cli-contract.md) defines the command, output, and exit-code surface used by the producer. The [OCI image contract](oci-image-contract.md) defines the image builder and publisher that gate the complete delivery caller. To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). ## Canonical workflow references The complete caller pins all four reusable workflows to one full revision. The GitHub Release path directly calls the producer and GitHub publisher: ```yaml -uses: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e +uses: meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA ``` ```yaml -uses: meigma/release/.github/workflows/publish-github-release.yml@fb8c8098ff27968fb3070e928c00e925f38c698e +uses: meigma/release/.github/workflows/publish-github-release.yml@FULL_SHA ``` The checksum signer identity input must name the same producer workflow revision: ```yaml -checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e +checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA ``` ## Caller contract @@ -45,9 +45,10 @@ jobs: name: Build release assets if: github.event.deleted == false permissions: + attestations: read contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e + uses: meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA oci-image: name: Build OCI image @@ -55,7 +56,7 @@ jobs: permissions: actions: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@fb8c8098ff27968fb3070e928c00e925f38c698e + uses: meigma/release/.github/workflows/go-oci-build.yml@FULL_SHA with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} @@ -70,7 +71,7 @@ jobs: contents: read id-token: write packages: write - uses: meigma/release/.github/workflows/publish-oci-image.yml@fb8c8098ff27968fb3070e928c00e925f38c698e + uses: meigma/release/.github/workflows/publish-oci-image.yml@FULL_SHA with: artifact-id: ${{ needs.oci-image.outputs.artifact-id }} artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} @@ -89,11 +90,11 @@ jobs: attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@fb8c8098ff27968fb3070e928c00e925f38c698e + uses: meigma/release/.github/workflows/publish-github-release.yml@FULL_SHA with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA require-oci-image: true oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} @@ -110,7 +111,15 @@ The caller concurrency key serializes runs for the same workflow and tag. `cance ### `go-pre-publish.yml` -The Go producer has no inputs and accepts no secrets. +The Go producer accepts one optional input and no secrets. + +| Input | Type | Required | Default | Value | +| --- | --- | --- | --- | --- | +| `cli-path` | string | No | Empty | Unsupported path to a caller-supplied `release-cli` binary. The caller owns the workflow-to-binary pairing. Normal consumers omit this input. | + +The producer loads `setup-release-cli` from the same pinned release revision +with `uses: $/.github/actions/setup-release-cli`. The caller does not pin the +action or CLI separately. | Output | Value | | --- | --- | @@ -122,6 +131,7 @@ The job requires these caller permissions: | Permission | Access | Use | | --- | --- | --- | +| `attestations` | `read` | Verify the downloaded `release-cli` archive attestation during setup. | | `contents` | `read` | Check out the consumer repository and its tag history. | | `id-token` | `write` | Obtain the OIDC identity used by keyless Cosign signing. | @@ -215,7 +225,7 @@ The repository must declare and lock these mise tool identifiers: - `aqua:sigstore/cosign` - `aqua:cli/cli` -The producer installs the first four tools. The publisher installs GitHub CLI and Cosign. Both workflows set `MISE_EXEC_AUTO_INSTALL=false` and invoke tools through `mise exec`; undeclared tools are not installed as a fallback. The producer also sets `GOTOOLCHAIN=local` and verifies that `go`, `goreleaser`, `syft`, and `cosign` resolve to their mise-managed executables. +The producer installs the first four tools. The publisher installs GitHub CLI and Cosign. Both workflows set `MISE_EXEC_AUTO_INSTALL=false` and invoke their managed tools through `mise exec`; undeclared tools are not installed as a fallback. The producer also sets `GOTOOLCHAIN=local` and verifies that `go`, `goreleaser`, `syft`, and `cosign` resolve to their mise-managed executables. The setup action separately requires the runner's `gh` command with attestation support and fails closed if either is unavailable. The canonical workflows install mise `2026.8.8`. These repository pins are the current known-compatible baseline, not versions selected automatically by the reusable workflows: @@ -255,6 +265,11 @@ The command also supplies `--skip=publish`. `release.disable: true` is the repos The project name, command path, and binary name are consumer values. The [copyable example](../../examples/go-release/) uses `example`, `./cmd/example`, and `example`. They are not inputs to the reusable workflow. +This repository's own project and binary name is `release-cli`, so its released +archive names start with `release-cli_`; for example, +`release-cli__linux_amd64.tar.gz`. Consumer repositories continue to +use their own project and binary names. + ## Authoritative artifact and asset contract The producer uploads one Actions artifact named `release-assets`. Its upload set is limited to: @@ -269,7 +284,7 @@ dist/checksums.txt.sigstore.json For the supported three-operating-system, two-architecture Go profile, this is six archives, six archive SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`: fourteen files in total. -Before upload, the producer runs `sha256sum --check checksums.txt` and requires a nonempty `checksums.txt.sigstore.json`. The publisher separately checks the Actions artifact metadata and downloads it with `digest-mismatch: error`. +Before upload, the producer obtains `release-cli` through the shared setup action and runs `release-cli stage --profile go --dist dist`. The command verifies every payload listed in `checksums.txt`, requires a nonempty regular `checksums.txt.sigstore.json`, and verifies the two canonical Linux binaries described in the [`release-cli` contract](release-cli-contract.md). The publisher separately checks the Actions artifact metadata and downloads it with `digest-mismatch: error`. `checksums.txt` is the authoritative payload list. It may end with a newline; every entry line must contain a 64-digit hexadecimal SHA-256 digest, a standard text or binary marker, and a flat filename matching this character set: @@ -296,14 +311,14 @@ The checksum signature is accepted only when Cosign verifies all of the followin | Field | Required value | | --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e` | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@FULL_SHA` | | Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | | Signed blob | `checksums.txt` | | Bundle | `checksums.txt.sigstore.json` | The exact identity comes from `checksum-signing-workflow-ref`; a branch name, tag name, different commit, or different workflow path does not satisfy the documented identity. -The publisher at `meigma/release/.github/workflows/publish-github-release.yml@fb8c8098ff27968fb3070e928c00e925f38c698e` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. +The publisher at `meigma/release/.github/workflows/publish-github-release.yml@FULL_SHA` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. ## Publication states diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md new file mode 100644 index 0000000..f4f2ba4 --- /dev/null +++ b/docs/reference/release-cli-contract.md @@ -0,0 +1,105 @@ +# `release-cli` contract reference + +`release-cli` validates and reports release data for the reusable workflows. The [GitHub Release contract](github-release-contract.md) defines the workflow inputs, artifacts, and publication behavior that surround the CLI. + +## Commands + +| Command | Purpose | +| --- | --- | +| `release-cli stage --profile go --dist PATH [--json]` | Validate the staged Go release files under `PATH`. | +| `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. | + +`--dist` is required for `stage`. The only accepted profile is `go`. + +## JSON output + +When option and argument parsing succeeds and `--json` is requested, stdout contains exactly one JSON document and no other output. The envelope has this structure: + +```text +{"schema":"release.dev/result/v1","command":"","ok":,"result":{...}} +``` + +| Field | Value | +| --- | --- | +| `schema` | Always `release.dev/result/v1`. | +| `command` | The command path, such as `stage` or `version`. | +| `ok` | `true` when the command succeeds; otherwise `false`. | +| `result` | The command-specific result object. | + +The `stage --json` result contains these fields: + +| Field | JSON type | Value | +| --- | --- | --- | +| `assets` | number | Number of payloads whose checksums matched. | +| `binaries` | object | Entries named `amd64` and `arm64` for the verified Linux binaries. | +| `binaries..path` | string | Original `/`-prefixed path from `artifacts.json`. | +| `binaries..mode` | string | Observed permission bits in octal notation. | + +The `version --json` result contains exactly these fields: + +| Field | JSON type | Value | +| --- | --- | --- | +| `version` | string | Release version stamped into the binary. | +| `commit` | string | Source commit stamped into the binary. | +| `protocol` | number | Protocol integer compiled into the CLI. The current value is `1`. | + +After successful parsing, a command failure under `--json` sets `ok` to `false` +and gives `result` one string field named `error`. The command also returns its +nonzero exit code. If parsing itself fails because of an unknown command or +flag, an invalid flag value, or the wrong number of arguments, no envelope is +written; the usage error goes to stderr and the process exits with code +`2`. + +Without `--json`, a successful `stage` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for both commands. + +## Exit codes + +| Code | Meaning | +| ---: | --- | +| `0` | The command completed successfully. | +| `1` | A release contract or verification check failed. The command fails closed. | +| `2` | Command usage or configuration is invalid. This includes an unsupported `--profile` value. | + +No other exit code is defined; in particular, code `3` has no meaning. An exit code does not make a general promise that a command is safe to run again. + +## Go staging profile + +`stage --profile go` validates the files already written under the distribution directory. It performs these checks: + +- `checksums.txt` contains at least one payload entry, and every listed payload matches its SHA-256 digest. +- `checksums.txt.sigstore.json` is a non-empty regular file. This stage requires the bundle but does not verify its signature. +- `artifacts.json` contains exactly two Linux `Binary` records: one for `amd64` and one for `arm64`. +- Each selected binary path starts with the distribution directory's basename; the remaining path is relative to that directory and remains confined beneath it. +- Each selected binary is a regular executable file. + +A failed check exits with code `1` and writes a diagnostic that identifies the offending artifact. The command does not persist a staging manifest or modify the files it validates. + +## Profiles + +`--profile` selects ecosystem-specific staging rules while keeping `stage` as the command. The current implementation dispatches `go` directly and rejects every other value with exit code `2`. New ecosystems extend the accepted profile values rather than adding ecosystem-specific top-level verbs. + +## Release unit and consumer pin + +The reusable workflows, `.github/actions/setup-release-cli`, and `release-cli` form one release unit and share one version. The producer loads the sibling action with `uses: $/.github/actions/setup-release-cli`. A consumer pins the workflow at one full commit SHA, and that self-reference selects the action from the same commit and its stamped default CLI version. `FULL_SHA` is the documentation placeholder for the released commit and will be replaced when this program's final pull request lands. Consumers cannot select an independent CLI version. + +The setup action has one optional input: + +| Input | Required | Meaning | +| --- | --- | --- | +| `cli-path` | No | Unsupported path to a caller-supplied `release-cli` binary. The caller owns the workflow-to-binary pairing. | + +With no `cli-path`, the action requires `github.action_repository` and a runner-provided `gh` with attestation support. It downloads exactly one Linux amd64 archive and `checksums.txt`, verifies the archive's unique SHA-256 entry, and runs `gh attestation verify` against the action repository with `--signer-workflow /.github/workflows/publish-github-release.yml` and `--deny-self-hosted-runners`. It then requires the binary's reported version and protocol to match the action stamps. A mismatch fails before the workflow invokes a CLI command. + +With `cli-path`, the action requires the supplied path to exist, be a regular file, and be executable, then runs `version --json`. A version or protocol mismatch produces a warning and continues. This path supports this repository's dogfood release, but it is not a compatibility promise. + +The action exposes `cli-path`, `reported-version`, and `reported-protocol` as outputs. + +## Released archive names + +GoReleaser names archives with this pattern: + +```text +release-cli___.tar.gz +``` + +Windows archives use `.zip`. For example, the Linux amd64 archive is `release-cli__linux_amd64.tar.gz`. The checksum manifest is `checksums.txt`. diff --git a/examples/go-release/README.md b/examples/go-release/README.md index e464a7c..ced4730 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -40,6 +40,12 @@ Replace these project-specific example values: - Manifest value `0.0.0` in `.release-please-manifest.json` if the consumer already has a release. Use its latest released version without the `v` prefix. - Linker variables `main.version` and `main.commit` in `.goreleaser.yaml` if the consumer command exposes version data through different variables. The copied sample defines both variables and prints `example ()` for `--version`. +The example uses one full commit SHA for every reusable workflow reference and +the checksum signing identity. That SHA is the consumer pin for the complete +release unit. The current pin, `fb8c8098ff27968fb3070e928c00e925f38c698e`, +is the last released revision. It will be refreshed when this program's final +pull request lands. + Keep these contract values unchanged: - all four reusable workflow references at `fb8c8098ff27968fb3070e928c00e925f38c698e`; diff --git a/examples/go-release/cmd/example/doc.go b/examples/go-release/cmd/example/doc.go new file mode 100644 index 0000000..37e47a2 --- /dev/null +++ b/examples/go-release/cmd/example/doc.go @@ -0,0 +1,5 @@ +// Package main is the copyable consumer example binary. +// +// It prints identity under --version so adopters can confirm the +// GoReleaser-stamped version and commit. +package main diff --git a/examples/go-release/cmd/example/main.go b/examples/go-release/cmd/example/main.go index 35efb87..172f7d8 100644 --- a/examples/go-release/cmd/example/main.go +++ b/examples/go-release/cmd/example/main.go @@ -11,10 +11,12 @@ var ( commit = "none" ) +// main is the process entrypoint. func main() { os.Exit(run()) } +// run parses flags and prints identity or a one-line description. func run() int { showVersion := flag.Bool("version", false, "print the project version and commit") flag.Parse() diff --git a/go.mod b/go.mod index 677bed2..ecce2c8 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,9 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5352f4f..5290212 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,29 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/doc.go b/internal/cli/doc.go new file mode 100644 index 0000000..0ded9bc --- /dev/null +++ b/internal/cli/doc.go @@ -0,0 +1,8 @@ +// Package cli implements the release-cli Cobra command tree. +// +// NewRootCommand builds a fresh command with injected streams and an optional +// [LookupEnv] seam. The tree exposes stage and version. Flags override +// RELEASE_* environment variables via [cobra.Flag.Changed]; there is no +// config file. ExitCode maps errors onto the process contract: 0 success, +// 1 verification failure, 2 usage or configuration error. +package cli diff --git a/internal/cli/result.go b/internal/cli/result.go new file mode 100644 index 0000000..d444e2f --- /dev/null +++ b/internal/cli/result.go @@ -0,0 +1,140 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +// Schema is the versioned JSON envelope identifier. +const Schema = "release.dev/result/v1" + +// Protocol is the workflow/binary contract integer. +// +// It is a source constant, not an ldflag, and is guarded by +// scripts/check-protocol-stamp.sh against EXPECTED_PROTOCOL in the +// setup-release-cli composite action. +const Protocol = 1 + +const ( + // exitSuccess is a successful command. + exitSuccess = 0 + // exitFailure is a contract or verification failure. + exitFailure = 1 + // exitUsage is a usage or configuration error. + exitUsage = 2 +) + +// ErrUsage marks a usage or configuration error (exit 2). +var ErrUsage = errors.New("usage") + +// Envelope is the single JSON document emitted under --json. +// +// Schema is always [Schema]. Command is the verb path ("stage" or "version"). +// OK is true only on success. Result is command-specific and must not be nil +// in a written document. A zero Envelope is invalid and is never encoded. +type Envelope struct { + // Schema identifies the envelope version. + Schema string `json:"schema"` + // Command is the verb path that produced the document. + Command string `json:"command"` + // OK is true when the command succeeded. + OK bool `json:"ok"` + // Result is the command-specific payload. + Result any `json:"result"` +} + +// VersionResult is the --json payload for version. +type VersionResult struct { + // Version is the stamped release version. + Version string `json:"version"` + // Commit is the stamped source commit. + Commit string `json:"commit"` + // Protocol is the workflow/binary contract integer. + Protocol int `json:"protocol"` +} + +// BinaryResult describes one verified canonical binary. +type BinaryResult struct { + // Path is the original GoReleaser path, including the --dist basename prefix. + Path string `json:"path"` + // Mode is the observed permission bits as an octal string. + Mode string `json:"mode"` +} + +// StageResult is the --json payload for stage. +type StageResult struct { + // Assets is the number of checksummed payloads that matched. + Assets int `json:"assets"` + // Binaries maps GOARCH onto the verified binary path and mode. + Binaries map[string]BinaryResult `json:"binaries"` +} + +// ErrorResult is the --json payload for a failed command. +type ErrorResult struct { + // Error is the diagnostic string also written to stderr. + Error string `json:"error"` +} + +// ExitCode maps err onto the process contract. +// +// nil is 0. Errors wrapping [ErrUsage] are 2. Every other error is 1. +func ExitCode(err error) int { + if err == nil { + return exitSuccess + } + if errors.Is(err, ErrUsage) { + return exitUsage + } + + return exitFailure +} + +// UsageError wraps err as a usage or configuration failure. +func UsageError(err error) error { + if err == nil { + return ErrUsage + } + + return fmt.Errorf("%w: %w", ErrUsage, err) +} + +// writeEnvelope writes one JSON result document to w. +func writeEnvelope(w io.Writer, command string, ok bool, result any) error { + payload, err := json.Marshal(Envelope{ + Schema: Schema, + Command: command, + OK: ok, + Result: result, + }) + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(w, string(payload)); err != nil { + return fmt.Errorf("write result: %w", err) + } + + return nil +} + +// writeCommandResult emits the --json envelope for a completed command. +// +// Flag-parse failures never reach this helper. A nil result with a nil error +// writes nothing, which is the silent success path. +func writeCommandResult(options Options, command string, result any, err error) error { + if options.settings == nil || !options.settings.JSON { + return err + } + if err != nil { + if writeErr := writeEnvelope(options.Out, command, false, ErrorResult{Error: err.Error()}); writeErr != nil { + return errors.Join(err, fmt.Errorf("write result: %w", writeErr)) + } + return err + } + if result == nil { + return nil + } + + return writeEnvelope(options.Out, command, true, result) +} diff --git a/internal/cli/result_test.go b/internal/cli/result_test.go new file mode 100644 index 0000000..1fb9c93 --- /dev/null +++ b/internal/cli/result_test.go @@ -0,0 +1,39 @@ +package cli_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/cli" +) + +func TestEnvelopeJSONShape(t *testing.T) { + t.Parallel() + + payload, err := json.Marshal(cli.Envelope{ + Schema: cli.Schema, + Command: "version", + OK: true, + Result: cli.VersionResult{ + Version: "1.0.0", + Commit: "deadbeef", + Protocol: cli.Protocol, + }, + }) + require.NoError(t, err) + assert.JSONEq(t, `{ + "schema":"release.dev/result/v1", + "command":"version", + "ok":true, + "result":{"version":"1.0.0","commit":"deadbeef","protocol":1} + }`, string(payload)) +} + +func TestProtocolConstant(t *testing.T) { + t.Parallel() + + assert.Equal(t, 1, cli.Protocol) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index ee70b62..06252e3 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,57 +1,116 @@ package cli import ( + "errors" "fmt" "io" + "os" + "strconv" "strings" "github.com/spf13/cobra" ) -// BuildInfo describes linker-injected build metadata printed by --version. +const ( + // commandName is the process name shown in help and version text. + commandName = "release-cli" + // envProfile is the environment variable for --profile. + envProfile = "RELEASE_PROFILE" + // envDist is the environment variable for --dist. + envDist = "RELEASE_DIST" + // envJSON is the environment variable for --json. + envJSON = "RELEASE_JSON" +) + +// LookupEnv looks up an environment variable. +// +// A nil LookupEnv uses [os.LookupEnv]. Tests inject a function to avoid +// process-global coupling when a future config reader is added. +type LookupEnv func(key string) (string, bool) + +// Settings is the resolved flag and environment configuration for one invocation. +// +// Flags win over environment variables. There is no config file. A zero +// Settings means nothing was resolved yet. +type Settings struct { + // Profile is the selected --profile / RELEASE_PROFILE value. + Profile string + // Dist is the selected --dist / RELEASE_DIST path. + Dist string + // JSON reports whether --json / RELEASE_JSON requested structured output. + JSON bool +} + +// BuildInfo describes linker-injected build metadata. type BuildInfo struct { // Version is the release version. Version string // Commit is the source commit used to build the binary. Commit string + // Protocol is the workflow/binary contract integer. + Protocol int } // Options customizes root command construction. type Options struct { // In receives command input. In io.Reader - // Out receives command output. + // Out receives machine-readable command output. Out io.Writer - // Err receives diagnostics. + // Err receives diagnostics and human-readable status. Err io.Writer - // Build controls the root command version output. + // LookupEnv resolves RELEASE_* variables. Nil selects [os.LookupEnv]. + LookupEnv LookupEnv + // Build controls version output. Build BuildInfo + // settings is filled after flags are parsed. + settings *Settings } -// NewRootCommand creates the release-mvp Cobra command tree. +// NewRootCommand creates the release-cli Cobra command tree. +// +// Streams, environment lookup, and build metadata are injected so tests never +// touch process globals. Nil streams become empty/discard streams. A nil +// LookupEnv uses [os.LookupEnv]. Blank version and commit default to "dev" +// and "none". A zero protocol defaults to [Protocol]. +// +// Flags override RELEASE_* environment variables. There is no config file. func NewRootCommand(options Options) *cobra.Command { options = options.withDefaults() root := &cobra.Command{ - Use: "release-mvp", - Short: "Exercise the Meigma release pipeline", - Version: options.Build.Version, + Use: commandName, + Short: "Stage and publish Meigma release artifacts", SilenceUsage: true, SilenceErrors: true, + Args: requireSubcommand, + RunE: func(_ *cobra.Command, _ []string) error { + return UsageError(errors.New("a subcommand is required")) + }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + *options.settings = resolveSettings(cmd, options.LookupEnv) + return nil + }, } - root.SetVersionTemplate(fmt.Sprintf( - "release-mvp %s (%s)\n", - options.Build.Version, - options.Build.Commit, - )) root.SetIn(options.In) root.SetOut(options.Out) root.SetErr(options.Err) - root.AddCommand(newGreetCommand()) + root.SetFlagErrorFunc(flagParseError) + root.PersistentFlags().Bool("json", false, "emit one JSON result document on stdout") + root.AddCommand(newStageCommand(options)) + root.AddCommand(newVersionCommand(options)) return root } +// flagParseError prints usage to stderr and classifies the parse failure as +// [ErrUsage]. Flag-parse failures never emit a JSON envelope. +func flagParseError(cmd *cobra.Command, err error) error { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), cmd.UsageString()) + return UsageError(err) +} + +// withDefaults fills nil streams, a nil LookupEnv, and blank build metadata. func (options Options) withDefaults() Options { if options.In == nil { options.In = strings.NewReader("") @@ -62,42 +121,79 @@ func (options Options) withDefaults() Options { if options.Err == nil { options.Err = io.Discard } + if options.LookupEnv == nil { + options.LookupEnv = os.LookupEnv + } + if options.settings == nil { + options.settings = &Settings{} + } if strings.TrimSpace(options.Build.Version) == "" { options.Build.Version = "dev" } if strings.TrimSpace(options.Build.Commit) == "" { options.Build.Commit = "none" } + if options.Build.Protocol == 0 { + options.Build.Protocol = Protocol + } return options } -func newGreetCommand() *cobra.Command { - var uppercase bool +// resolveSettings applies flag-over-env precedence for the executing command. +func resolveSettings(cmd *cobra.Command, lookup LookupEnv) Settings { + return Settings{ + Profile: resolveString(cmd, flagProfile, envProfile, lookup), + Dist: resolveString(cmd, flagDist, envDist, lookup), + JSON: resolveBool(cmd, "json", envJSON, lookup), + } +} - cmd := &cobra.Command{ - Use: "greet [name]", - Short: "Print a greeting", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - name := "world" - if len(args) == 1 { - name = args[0] - } +// resolveString returns the flag value when the flag was set, otherwise the +// named environment variable, otherwise the flag default. +func resolveString(cmd *cobra.Command, flagName, envName string, lookup LookupEnv) string { + if flag := cmd.Flags().Lookup(flagName); flag != nil && flag.Changed { + return flag.Value.String() + } + if value, ok := lookup(envName); ok { + return value + } + if flag := cmd.Flags().Lookup(flagName); flag != nil { + return flag.Value.String() + } - greeting := fmt.Sprintf("Hello, %s!", name) - if uppercase { - greeting = strings.ToUpper(greeting) - } + return "" +} - if _, err := fmt.Fprintln(cmd.OutOrStdout(), greeting); err != nil { - return fmt.Errorf("write greeting: %w", err) - } +// resolveBool returns the flag value when the flag was set, otherwise the +// named environment variable parsed as a bool, otherwise false. +func resolveBool(cmd *cobra.Command, flagName, envName string, lookup LookupEnv) bool { + if flag := cmd.Flags().Lookup(flagName); flag != nil && flag.Changed { + value, err := strconv.ParseBool(flag.Value.String()) + return err == nil && value + } + if raw, ok := lookup(envName); ok { + value, err := strconv.ParseBool(raw) + return err == nil && value + } - return nil - }, + return false +} + +// usageNoArgs rejects positional arguments as a usage error. +func usageNoArgs(cmd *cobra.Command, args []string) error { + if err := cobra.NoArgs(cmd, args); err != nil { + return UsageError(err) + } + + return nil +} + +// requireSubcommand rejects a missing or unknown verb as [ErrUsage]. +func requireSubcommand(_ *cobra.Command, args []string) error { + if len(args) == 0 { + return UsageError(errors.New("a subcommand is required")) } - cmd.Flags().BoolVar(&uppercase, "uppercase", false, "print the greeting in uppercase") - return cmd + return UsageError(fmt.Errorf("unknown command %q", args[0])) } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 70bc9dd..088a7e6 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -2,6 +2,12 @@ package cli_test import ( "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -10,54 +16,366 @@ import ( "github.com/meigma/release/internal/cli" ) -func TestGreet(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - args []string - want string - }{ - "default name": { - args: []string{"greet"}, - want: "Hello, world!\n", - }, - "provided name": { - args: []string{"greet", "Meigma"}, - want: "Hello, Meigma!\n", - }, - "uppercase": { - args: []string{"greet", "Meigma", "--uppercase"}, - want: "HELLO, MEIGMA!\n", - }, - } +func TestVersionHuman(t *testing.T) { + t.Parallel() - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() + stdout, stderr, err := execute(t, nil, []string{"version"}, cli.BuildInfo{ + Version: "1.2.3", + Commit: "abc1234", + Protocol: 1, + }) + require.NoError(t, err) + assert.Equal(t, "release-cli 1.2.3 (abc1234, protocol 1)\n", stdout) + assert.Empty(t, stderr) +} - output := &bytes.Buffer{} - command := cli.NewRootCommand(cli.Options{Out: output}) - command.SetArgs(test.args) +func TestVersionJSON(t *testing.T) { + t.Parallel() - require.NoError(t, command.Execute()) - assert.Equal(t, test.want, output.String()) - }) - } + stdout, stderr, err := execute(t, nil, []string{"version", "--json"}, cli.BuildInfo{ + Version: "1.2.3", + Commit: "abc1234", + Protocol: 1, + }) + require.NoError(t, err) + assert.Empty(t, stderr) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, "version", envelope.Command) + assert.True(t, envelope.OK) + assert.Equal(t, 1, countJSONDocuments(stdout)) + + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.VersionResult + require.NoError(t, json.Unmarshal(raw, &result)) + assert.Equal(t, "1.2.3", result.Version) + assert.Equal(t, "abc1234", result.Commit) + assert.Equal(t, 1, result.Protocol) +} + +func TestStageUnknownProfileIsUsage(t *testing.T) { + t.Parallel() + + stdout, stderr, err := execute( + t, + nil, + []string{"stage", "--profile", "rust", "--dist", t.TempDir()}, + cli.BuildInfo{}, + ) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.Contains(t, err.Error(), "unknown profile") +} + +func TestStageUnknownProfileJSON(t *testing.T) { + t.Parallel() + + stdout, stderr, err := execute( + t, + nil, + []string{"stage", "--profile", "rust", "--dist", t.TempDir(), "--json"}, + cli.BuildInfo{}, + ) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stderr) + assertFailureEnvelope(t, stdout, "unknown profile") +} + +func TestStageEnvOnly(t *testing.T) { + stdout, stderr, err := execute(t, map[string]string{ + "RELEASE_PROFILE": "go", + "RELEASE_DIST": goodDist(t), + "RELEASE_JSON": "true", + }, []string{"stage"}, cli.BuildInfo{}) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, 1, countJSONDocuments(stdout)) + assert.Contains(t, stdout, `"ok":true`) +} + +func TestBareInvocationRequiresSubcommand(t *testing.T) { + t.Parallel() + + stdout, _, err := execute(t, nil, []string{"--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), "a subcommand is required") +} + +func TestAssetNamedUnknownFlagIsExitOne(t *testing.T) { + t.Parallel() + + dist := goodDist(t) + digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + writeFile(t, filepath.Join(dist, "checksums.txt"), digest+" unknown flag\n") + + stdout, _, err := execute(t, nil, []string{"stage", "--profile", "go", "--dist", dist, "--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "unknown flag") + assertFailureEnvelope(t, stdout, "unknown flag") } -func TestVersion(t *testing.T) { +func TestStageJSONValidationFailure(t *testing.T) { t.Parallel() - output := &bytes.Buffer{} + dist := goodDist(t) + require.NoError(t, os.WriteFile(filepath.Join(dist, "archive.tar.gz"), []byte("mutated"), 0o644)) + + stdout, stderr, err := execute( + t, + nil, + []string{"stage", "--profile", "go", "--dist", dist, "--json"}, + cli.BuildInfo{}, + ) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Empty(t, stderr) + assertFailureEnvelope(t, stdout, "archive.tar.gz") +} + +func TestStageUnknownFlagNoEnvelope(t *testing.T) { + t.Parallel() + + stdout, stderr, err := execute(t, nil, []string{"stage", "--json", "--not-a-flag"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "Usage:") +} + +func TestUnknownCommandNoEnvelope(t *testing.T) { + t.Parallel() + + stdout, stderr, err := execute(t, nil, []string{"publish", "--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), "unknown command") + assert.Empty(t, stderr) +} + +func TestStageFlagOverridesEnv(t *testing.T) { + stdout, _, err := execute(t, map[string]string{ + "RELEASE_PROFILE": "rust", + "RELEASE_DIST": t.TempDir(), + }, []string{"stage", "--profile", "go", "--dist", goodDist(t), "--json"}, cli.BuildInfo{}) + require.NoError(t, err) + assert.Equal(t, 1, countJSONDocuments(stdout)) + assert.Contains(t, stdout, `"ok":true`) + assert.Contains(t, stdout, `"command":"stage"`) +} + +func TestStageSuccessSilentWithoutJSON(t *testing.T) { + t.Parallel() + + stdout, stderr, err := execute(t, nil, []string{"stage", "--profile", "go", "--dist", goodDist(t)}, cli.BuildInfo{}) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestStageJSONSuccess(t *testing.T) { + t.Parallel() + + stdout, stderr, err := execute( + t, + nil, + []string{"stage", "--profile", "go", "--dist", goodDist(t), "--json"}, + cli.BuildInfo{}, + ) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.True(t, envelope.OK) + assert.Equal(t, "stage", envelope.Command) +} + +func TestStageBadChecksum(t *testing.T) { + t.Parallel() + + dist := goodDist(t) + require.NoError(t, os.WriteFile(filepath.Join(dist, "archive.tar.gz"), []byte("mutated"), 0o644)) + + stdout, _, err := execute(t, nil, []string{"stage", "--profile", "go", "--dist", dist, "--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "archive.tar.gz") + assertFailureEnvelope(t, stdout, "archive.tar.gz") +} + +func TestStageMissingArchitectureRecord(t *testing.T) { + t.Parallel() + + dist := goodDist(t) + writeFile(t, filepath.Join(dist, "artifacts.json"), `[ + {"type":"Binary","goos":"linux","goarch":"amd64","path":"dist/app_linux_amd64/app","name":"app"} + ]`) + + stdout, _, err := execute(t, nil, []string{"stage", "--profile", "go", "--dist", dist, "--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "missing linux Binary record for arm64") + assertFailureEnvelope(t, stdout, "missing linux Binary record for arm64") +} + +func TestStageEscapedPath(t *testing.T) { + t.Parallel() + + dist := goodDist(t) + writeFile(t, filepath.Join(dist, "artifacts.json"), `[ + {"type":"Binary","goos":"linux","goarch":"amd64","path":"dist/../secret","name":"secret"}, + {"type":"Binary","goos":"linux","goarch":"arm64","path":"dist/app_linux_arm64/app","name":"app"} + ]`) + + stdout, _, err := execute(t, nil, []string{"stage", "--profile", "go", "--dist", dist, "--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "escapes the dist root") + assertFailureEnvelope(t, stdout, "escapes the dist root") +} + +func TestStageClearedExecuteBit(t *testing.T) { + t.Parallel() + + dist := goodDist(t) + require.NoError(t, os.Chmod(filepath.Join(dist, "app_linux_arm64", "app"), 0o644)) + + stdout, _, err := execute(t, nil, []string{"stage", "--profile", "go", "--dist", dist, "--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "is not executable") + assertFailureEnvelope(t, stdout, "is not executable") +} + +func TestStageSymlinkEscape(t *testing.T) { + t.Parallel() + + dist := goodDist(t) + outside := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o755)) + link := filepath.Join(dist, "app_linux_amd64", "app") + require.NoError(t, os.Remove(link)) + require.NoError(t, os.Symlink(outside, link)) + + stdout, _, err := execute(t, nil, []string{"stage", "--profile", "go", "--dist", dist, "--json"}, cli.BuildInfo{}) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "app_linux_amd64/app") + assertFailureEnvelope(t, stdout, "app_linux_amd64/app") +} + +func TestExitCode(t *testing.T) { + t.Parallel() + + assert.Equal(t, 0, cli.ExitCode(nil)) + assert.Equal(t, 2, cli.ExitCode(cli.UsageError(assert.AnError))) + assert.Equal(t, 1, cli.ExitCode(assert.AnError)) +} + +// execute runs the command tree with injected streams and the given env. +func execute( + t *testing.T, + env map[string]string, + args []string, + build cli.BuildInfo, +) (string, string, error) { + t.Helper() + + for key, value := range env { + t.Setenv(key, value) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} command := cli.NewRootCommand(cli.Options{ - Out: output, - Build: cli.BuildInfo{ - Version: "1.2.3", - Commit: "abc1234", - }, + Out: stdout, + Err: stderr, + Build: build, }) - command.SetArgs([]string{"--version"}) + command.SetArgs(args) + err := command.Execute() + + return stdout.String(), stderr.String(), err +} + +// assertFailureEnvelope checks stdout is one ok:false stage envelope. +func assertFailureEnvelope(t *testing.T, stdout, wantError string) { + t.Helper() + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, "stage", envelope.Command) + assert.False(t, envelope.OK) + + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.ErrorResult + require.NoError(t, json.Unmarshal(raw, &result)) + if wantError != "" { + assert.Contains(t, result.Error, wantError) + } + assert.NotEmpty(t, result.Error) +} + +// countJSONDocuments returns how many JSON values stdout contains. +func countJSONDocuments(stdout string) int { + decoder := json.NewDecoder(strings.NewReader(strings.TrimSpace(stdout))) + count := 0 + for decoder.More() { + var document json.RawMessage + if err := decoder.Decode(&document); err != nil { + return -1 + } + count++ + } + + return count +} + +// goodDist writes a valid Go profile bundle under a directory named dist. +func goodDist(t *testing.T) string { + t.Helper() + + root := filepath.Join(t.TempDir(), "dist") + payload := []byte("archive") + sum := sha256.Sum256(payload) + writeFile(t, filepath.Join(root, "archive.tar.gz"), "archive") + writeFile(t, filepath.Join(root, "checksums.txt"), hex.EncodeToString(sum[:])+" archive.tar.gz\n") + writeFile(t, filepath.Join(root, "checksums.txt.sigstore.json"), "{bundle}") + writeExec(t, filepath.Join(root, "app_linux_amd64", "app"), []byte("amd64")) + writeExec(t, filepath.Join(root, "app_linux_arm64", "app"), []byte("arm64")) + writeFile(t, filepath.Join(root, "artifacts.json"), `[ + {"type":"Binary","goos":"linux","goarch":"amd64","path":"dist/app_linux_amd64/app","name":"app"}, + {"type":"Binary","goos":"linux","goarch":"arm64","path":"dist/app_linux_arm64/app","name":"app"} + ]`) + + return root +} + +// writeFile creates path with data, making parent directories as needed. +func writeFile(t *testing.T, path, data string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) +} - require.NoError(t, command.Execute()) - assert.Equal(t, "release-mvp 1.2.3 (abc1234)\n", output.String()) +// writeExec creates an owner-executable file at path. +func writeExec(t *testing.T, path string, data []byte) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, data, 0o755)) + require.NoError(t, os.Chmod(path, 0o755)) } diff --git a/internal/cli/stage.go b/internal/cli/stage.go new file mode 100644 index 0000000..d2b8c3d --- /dev/null +++ b/internal/cli/stage.go @@ -0,0 +1,91 @@ +package cli + +import ( + "fmt" + "os" + "path" + "strconv" + + "github.com/spf13/cobra" + + "github.com/meigma/release/internal/profile/goprof" + "github.com/meigma/release/internal/stage" +) + +const ( + // profileGo is the only accepted --profile value in this slice. + profileGo = "go" + // flagProfile is the stage profile flag name. + flagProfile = "profile" + // flagDist is the stage dist-directory flag name. + flagDist = "dist" + // octalBase formats file modes as octal strings. + octalBase = 8 +) + +// newStageCommand constructs the stage verb. +func newStageCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "stage", + Short: "Verify a profile-specific dist directory", + Args: usageNoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runStage(options) + }, + } + cmd.Flags().String(flagProfile, "", "release profile (only go is supported)") + cmd.Flags().String(flagDist, "", "path to the GoReleaser dist directory") + + return cmd +} + +// runStage validates flags and verifies the Go profile dist directory. +func runStage(options Options) error { + settings := *options.settings + if settings.Profile == "" { + return writeCommandResult(options, "stage", nil, UsageError(fmt.Errorf("--%s is required", flagProfile))) + } + if settings.Profile != profileGo { + return writeCommandResult( + options, + "stage", + nil, + UsageError(fmt.Errorf("unknown profile %q (supported: %s)", settings.Profile, profileGo)), + ) + } + + if settings.Dist == "" { + return writeCommandResult(options, "stage", nil, UsageError(fmt.Errorf("--%s is required", flagDist))) + } + + root, err := os.OpenRoot(settings.Dist) + if err != nil { + return writeCommandResult(options, "stage", nil, fmt.Errorf("open dist %s: %w", settings.Dist, err)) + } + defer root.Close() + + rootName, err := goprof.ParseRootName(path.Base(settings.Dist)) + if err != nil { + return writeCommandResult(options, "stage", nil, err) + } + report, err := stage.Stage(root.FS(), rootName) + if err != nil { + return writeCommandResult(options, "stage", nil, err) + } + if !settings.JSON { + return nil + } + + result := StageResult{ + Assets: report.Assets, + Binaries: make(map[string]BinaryResult, len(report.Binaries)), + } + for _, binary := range report.Binaries { + result.Binaries[binary.Arch] = BinaryResult{ + Path: binary.Path, + Mode: strconv.FormatUint(uint64(binary.Mode), octalBase), + } + } + + return writeCommandResult(options, "stage", result, nil) +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..e3ca019 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,44 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newVersionCommand constructs the version verb. +func newVersionCommand(options Options) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version, commit, and protocol", + Args: usageNoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runVersion(options) + }, + } +} + +// runVersion writes the human or JSON version identity. +func runVersion(options Options) error { + if options.settings != nil && options.settings.JSON { + return writeCommandResult(options, "version", VersionResult{ + Version: options.Build.Version, + Commit: options.Build.Commit, + Protocol: options.Build.Protocol, + }, nil) + } + + _, err := fmt.Fprintf( + options.Out, + "%s %s (%s, protocol %d)\n", + commandName, + options.Build.Version, + options.Build.Commit, + options.Build.Protocol, + ) + if err != nil { + return fmt.Errorf("write version: %w", err) + } + + return nil +} diff --git a/internal/profile/goprof/artifacts.go b/internal/profile/goprof/artifacts.go new file mode 100644 index 0000000..3423888 --- /dev/null +++ b/internal/profile/goprof/artifacts.go @@ -0,0 +1,311 @@ +package goprof + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "path/filepath" + "slices" + "strings" +) + +const ( + // binaryType is GoReleaser's compiled-binary artifact type. + binaryType = "Binary" + // linuxOS is the required GOOS for canonical binaries. + linuxOS = "linux" + // ownerExecute is the owner-execute permission bit. + ownerExecute = 0o100 +) + +// Record is one GoReleaser artifacts.json entry. +// +// Fields are bare strings because this type mirrors GoReleaser's wire format +// at the decode boundary. Validation happens in [SelectBinaries], not here. +// A zero Record is an empty decoded object and is ignored by selection. +type Record struct { + // Type is the GoReleaser artifact type, for example Binary or Archive. + Type string `json:"type"` + // GOOS is the target operating system. + GOOS string `json:"goos"` + // GOARCH is the target architecture. + GOARCH string `json:"goarch"` + // Path is the GoReleaser-written path, prefixed with the --dist basename. + Path string `json:"path"` + // Name is the artifact filename. + Name string `json:"name"` +} + +// RootName is the basename of the --dist directory. +// +// GoReleaser prefixes artifact paths with this name. The zero value is +// invalid; construct with [ParseRootName]. +type RootName string + +// Arch is a selected Linux GOARCH. +// +// Only amd64 and arm64 are valid after [SelectBinaries]. The zero value is +// invalid; construct with [ParseArch]. +type Arch string + +// ArtifactPath is a GoReleaser-written artifact path. +// +// It includes the [RootName] prefix. The zero value is invalid; construct +// with [ParseArtifactPath]. +type ArtifactPath string + +// RelativePath is an artifact path confined under the dist root for [io/fs.FS]. +// +// The zero value is invalid; construct with [ParseRelativePath]. +type RelativePath string + +// CanonicalBinary is a selected linux/{amd64,arm64} Binary record. +// +// Values are produced only by [SelectBinaries]. A zero CanonicalBinary is +// invalid and must not be verified. +type CanonicalBinary struct { + // Arch is the selected GOARCH. + Arch Arch + // Path is the original GoReleaser path, including the --dist basename prefix. + Path ArtifactPath + // RelativePath is Path with the leading root name stripped for [io/fs.FS] lookup. + RelativePath RelativePath +} + +// ParseRootName constructs a [RootName] from a directory basename. +func ParseRootName(raw string) (RootName, error) { + if raw == "" || raw == "." || raw == string(filepath.Separator) { + return "", fmt.Errorf("dist root name %q is empty", raw) + } + if strings.ContainsRune(raw, filepath.Separator) { + return "", fmt.Errorf("dist root name %q is not a basename", raw) + } + + return RootName(raw), nil +} + +// String returns the directory basename. +func (n RootName) String() string { + return string(n) +} + +// ParseArch constructs an [Arch] from a required Linux GOARCH. +func ParseArch(raw string) (Arch, error) { + if !slices.Contains(requiredArchs(), raw) { + return "", fmt.Errorf("unsupported architecture %q", raw) + } + + return Arch(raw), nil +} + +// String returns the GOARCH value. +func (a Arch) String() string { + return string(a) +} + +// ParseArtifactPath constructs an [ArtifactPath] from a nonempty path. +func ParseArtifactPath(raw string) (ArtifactPath, error) { + if raw == "" { + return "", errors.New("artifact path is empty") + } + + return ArtifactPath(raw), nil +} + +// String returns the original GoReleaser path. +func (p ArtifactPath) String() string { + return string(p) +} + +// ParseRelativePath constructs a confined [RelativePath]. +func ParseRelativePath(raw string) (RelativePath, error) { + if err := confine(raw); err != nil { + return "", err + } + + return RelativePath(raw), nil +} + +// String returns the dist-root-relative path. +func (p RelativePath) String() string { + return string(p) +} + +// requiredArchs returns the closed set of Linux GOARCH values that must +// each appear exactly once as a Binary record. +func requiredArchs() []string { + return []string{"amd64", "arm64"} +} + +// ParseArtifacts decodes a GoReleaser artifacts.json document. +// +// Unknown fields are ignored. The document must be a JSON array. A nil reader +// is rejected. +func ParseArtifacts(r io.Reader) ([]Record, error) { + if r == nil { + return nil, errors.New("artifacts reader is nil") + } + + return parseArtifacts(r) +} + +// parseArtifacts decodes after the exported nil check. +func parseArtifacts(r io.Reader) ([]Record, error) { + decoder := json.NewDecoder(r) + var records []Record + if err := decoder.Decode(&records); err != nil { + return nil, fmt.Errorf("decode artifacts.json: %w", err) + } + + return records, nil +} + +// SelectBinaries returns exactly one linux Binary per required architecture. +// +// Zero, duplicate, or missing architectures fail with a diagnostic that names +// the architectures that were found. Paths must be relative to root, the +// basename of the --dist directory (GoReleaser writes "/..."). +func SelectBinaries(records []Record, root RootName) ([]CanonicalBinary, error) { + if root == "" { + return nil, errors.New("dist root name is empty") + } + required := requiredArchs() + selected := make(map[Arch]CanonicalBinary, len(required)) + var found []string + + for _, record := range records { + if record.Type != binaryType || record.GOOS != linuxOS { + continue + } + found = append(found, record.GOARCH) + arch, err := ParseArch(record.GOARCH) + if err != nil { + return nil, fmt.Errorf( + "unexpected linux/%s Binary record; found %s", + record.GOARCH, + joinArchs(found), + ) + } + if _, exists := selected[arch]; exists { + return nil, fmt.Errorf( + "duplicate linux/%s Binary record; found %s", + record.GOARCH, + joinArchs(found), + ) + } + relative, err := rootRelative(record.Path, root) + if err != nil { + return nil, err + } + artifactPath, err := ParseArtifactPath(record.Path) + if err != nil { + return nil, err + } + selected[arch] = CanonicalBinary{ + Arch: arch, + Path: artifactPath, + RelativePath: relative, + } + } + + var missing []string + out := make([]CanonicalBinary, 0, len(required)) + for _, name := range required { + arch := Arch(name) + binary, ok := selected[arch] + if !ok { + missing = append(missing, name) + continue + } + out = append(out, binary) + } + if len(missing) > 0 { + return nil, fmt.Errorf( + "missing linux Binary record for %s; found %s", + joinArchs(missing), + joinArchs(found), + ) + } + + return out, nil +} + +// VerifyBinaries checks each selected path against fsys. +// +// Every path must be lexically confined under the dist root, a regular file, +// and have the owner-execute bit set. The caller supplies [os.OpenRoot] of +// the dist directory as [fs.FS]. A nil filesystem is rejected. +func VerifyBinaries(fsys fs.FS, binaries []CanonicalBinary) error { + if fsys == nil { + return errors.New("filesystem is nil") + } + + return verifyBinaries(fsys, binaries) +} + +// verifyBinaries checks binaries after the exported nil check. +func verifyBinaries(fsys fs.FS, binaries []CanonicalBinary) error { + for _, binary := range binaries { + if err := verifyBinary(fsys, binary); err != nil { + return err + } + } + + return nil +} + +// verifyBinary checks lexical confinement, regularity, and the execute bit. +func verifyBinary(fsys fs.FS, binary CanonicalBinary) error { + if err := confine(binary.RelativePath.String()); err != nil { + return fmt.Errorf("%s: %w", binary.Path, err) + } + + info, err := fs.Stat(fsys, binary.RelativePath.String()) + if err != nil { + return fmt.Errorf("%s: %w", binary.Path, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", binary.Path) + } + if info.Mode().Perm()&ownerExecute == 0 { + return fmt.Errorf("%s is not executable", binary.Path) + } + + return nil +} + +// rootRelative strips the leading "/" prefix and confines the remainder. +func rootRelative(raw string, root RootName) (RelativePath, error) { + prefix := root.String() + "/" + relative, ok := strings.CutPrefix(raw, prefix) + if !ok { + return "", fmt.Errorf("artifact path %q is not %s/-relative", raw, root) + } + + return ParseRelativePath(relative) +} + +// confine rejects empty, absolute, and lexically escaping relative paths. +func confine(relative string) error { + if relative == "" || !filepath.IsLocal(relative) { + if relative != "" && + (filepath.Clean(relative) == ".." || strings.HasPrefix(filepath.ToSlash(filepath.Clean(relative)), "../")) { + return fmt.Errorf("path %q escapes the dist root", relative) + } + + return fmt.Errorf("path %q is not confined under the dist root", relative) + } + + return nil +} + +// joinArchs formats architecture names for diagnostics. +func joinArchs(archs []string) string { + if len(archs) == 0 { + return "none" + } + + return strings.Join(archs, ", ") +} diff --git a/internal/profile/goprof/artifacts_test.go b/internal/profile/goprof/artifacts_test.go new file mode 100644 index 0000000..7aa81c3 --- /dev/null +++ b/internal/profile/goprof/artifacts_test.go @@ -0,0 +1,306 @@ +package goprof_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/profile/goprof" +) + +func TestParseArtifactsMalformedJSON(t *testing.T) { + t.Parallel() + + _, err := goprof.ParseArtifacts(strings.NewReader("{not-an-array")) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode artifacts.json") +} + +func TestSelectBinaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rootName string + records []goprof.Record + want []goprof.CanonicalBinary + wantErr string + }{ + { + name: "selects one linux binary per required architecture", + rootName: "dist", + records: []goprof.Record{ + linuxBinary("amd64", "dist/release-cli_linux_amd64/release-cli"), + linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), + {Type: "Archive", GOOS: "linux", GOARCH: "amd64", Path: "dist/archive.tar.gz", Name: "archive.tar.gz"}, + { + Type: "Binary", + GOOS: "darwin", + GOARCH: "amd64", + Path: "dist/release-cli_darwin_amd64/release-cli", + Name: "release-cli", + }, + }, + want: []goprof.CanonicalBinary{ + canonical("amd64", "dist/release-cli_linux_amd64/release-cli", "release-cli_linux_amd64/release-cli"), + canonical("arm64", "dist/release-cli_linux_arm64/release-cli", "release-cli_linux_arm64/release-cli"), + }, + }, + { + name: "non-dist directory name", + rootName: "build", + records: []goprof.Record{ + linuxBinary("amd64", "build/app_linux_amd64/app"), + linuxBinary("arm64", "build/app_linux_arm64/app"), + }, + want: []goprof.CanonicalBinary{ + canonical("amd64", "build/app_linux_amd64/app", "app_linux_amd64/app"), + canonical("arm64", "build/app_linux_arm64/app", "app_linux_arm64/app"), + }, + }, + { + name: "wrong type is ignored then missing architecture fails", + rootName: "dist", + records: []goprof.Record{ + {Type: "Archive", GOOS: "linux", GOARCH: "amd64", Path: "dist/a.tar.gz", Name: "a.tar.gz"}, + linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), + }, + wantErr: "missing linux Binary record for amd64; found arm64", + }, + { + name: "wrong goos is ignored then missing architecture fails", + rootName: "dist", + records: []goprof.Record{ + { + Type: "Binary", + GOOS: "darwin", + GOARCH: "amd64", + Path: "dist/release-cli_darwin_amd64/release-cli", + Name: "release-cli", + }, + linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), + }, + wantErr: "missing linux Binary record for amd64; found arm64", + }, + { + name: "duplicate architecture", + rootName: "dist", + records: []goprof.Record{ + linuxBinary("amd64", "dist/release-cli_linux_amd64/release-cli"), + linuxBinary("amd64", "dist/release-cli_linux_amd64_alt/release-cli"), + linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), + }, + wantErr: "duplicate linux/amd64 Binary record", + }, + { + name: "missing architecture", + rootName: "dist", + records: []goprof.Record{ + linuxBinary("amd64", "dist/release-cli_linux_amd64/release-cli"), + }, + wantErr: "missing linux Binary record for arm64; found amd64", + }, + { + name: "unexpected extra architecture", + rootName: "dist", + records: []goprof.Record{ + linuxBinary("amd64", "dist/release-cli_linux_amd64/release-cli"), + linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), + linuxBinary("s390x", "dist/release-cli_linux_s390x/release-cli"), + }, + wantErr: "unexpected linux/s390x Binary record", + }, + { + name: "lexical escape", + rootName: "dist", + records: []goprof.Record{ + linuxBinary("amd64", "dist/../secret"), + linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), + }, + wantErr: "escapes the dist root", + }, + { + name: "absolute path", + rootName: "dist", + records: []goprof.Record{ + linuxBinary("amd64", "/etc/passwd"), + linuxBinary("arm64", "dist/release-cli_linux_arm64/release-cli"), + }, + wantErr: "is not dist/-relative", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := goprof.SelectBinaries(test.records, mustRoot(t, test.rootName)) + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestParseArtifactsRealFixture(t *testing.T) { + t.Parallel() + + // Refresh with: + // mise exec -- goreleaser build --snapshot --clean + // then copy dist/artifacts.json over testdata/goreleaser-2.17.1-artifacts.json + // and delete dist/. + file, err := os.Open(filepath.Join("testdata", "goreleaser-2.17.1-artifacts.json")) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, file.Close()) + }) + + records, err := goprof.ParseArtifacts(file) + require.NoError(t, err) + require.NotEmpty(t, records) + + var binaries []goprof.Record + for _, record := range records { + if record.Type != "Binary" { + continue + } + assert.NotEmpty(t, record.Path) + assert.NotEmpty(t, record.Name) + assert.NotEmpty(t, record.GOOS) + assert.NotEmpty(t, record.GOARCH) + binaries = append(binaries, record) + } + require.NotEmpty(t, binaries, "fixture must contain Binary records") + + selected, err := goprof.SelectBinaries(records, mustRoot(t, "dist")) + require.NoError(t, err) + require.Len(t, selected, 2) + assert.Equal(t, "amd64", selected[0].Arch.String()) + assert.Equal(t, "arm64", selected[1].Arch.String()) + assert.True(t, strings.HasPrefix(selected[0].Path.String(), "dist/")) + assert.True(t, strings.HasPrefix(selected[1].Path.String(), "dist/")) +} + +func TestVerifyBinariesMapFS(t *testing.T) { + t.Parallel() + + binaries := []goprof.CanonicalBinary{ + canonical("amd64", "dist/app_linux_amd64/app", "app_linux_amd64/app"), + canonical("arm64", "dist/app_linux_arm64/app", "app_linux_arm64/app"), + } + + err := goprof.VerifyBinaries(fstest.MapFS{ + "app_linux_amd64/app": {Data: []byte("amd64"), Mode: 0o755}, + "app_linux_arm64/app": {Data: []byte("arm64"), Mode: 0o755}, + }, binaries) + require.NoError(t, err) + + err = goprof.VerifyBinaries(fstest.MapFS{ + "app_linux_amd64/app": {Data: []byte("amd64"), Mode: 0o755}, + }, binaries) + require.Error(t, err) + assert.Contains(t, err.Error(), "app_linux_arm64/app") +} + +func TestVerifyBinariesTempDir(t *testing.T) { + t.Parallel() + + root := t.TempDir() + amd64Path := filepath.Join("app_linux_amd64", "app") + arm64Path := filepath.Join("app_linux_arm64", "app") + writeExec(t, filepath.Join(root, amd64Path), []byte("amd64")) + writeExec(t, filepath.Join(root, arm64Path), []byte("arm64")) + + binaries := []goprof.CanonicalBinary{ + canonical("amd64", "dist/app_linux_amd64/app", filepath.ToSlash(amd64Path)), + canonical("arm64", "dist/app_linux_arm64/app", filepath.ToSlash(arm64Path)), + } + require.NoError(t, goprof.VerifyBinaries(os.DirFS(root), binaries)) + + require.NoError(t, os.Chmod(filepath.Join(root, arm64Path), 0o644)) + err := goprof.VerifyBinaries(os.DirFS(root), binaries) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not executable") +} + +func TestCanonicalBinaryJSONRoundTripFields(t *testing.T) { + t.Parallel() + + raw := `[{"type":"Binary","goos":"linux","goarch":"amd64","path":"dist/app","name":"app","extra":{"ignored":true}}]` + records, err := goprof.ParseArtifacts(strings.NewReader(raw)) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, "Binary", records[0].Type) + assert.Equal(t, "linux", records[0].GOOS) + assert.Equal(t, "amd64", records[0].GOARCH) + assert.Equal(t, "dist/app", records[0].Path) + assert.Equal(t, "app", records[0].Name) + + encoded, err := json.Marshal(records[0]) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"type":"Binary"`) +} + +// linuxBinary builds a linux Binary record for tests. +func linuxBinary(arch, path string) goprof.Record { + return goprof.Record{ + Type: "Binary", + GOOS: "linux", + GOARCH: arch, + Path: path, + Name: "release-cli", + } +} + +// canonical builds a CanonicalBinary from already-valid test strings. +func canonical(arch, path, relative string) goprof.CanonicalBinary { + return goprof.CanonicalBinary{ + Arch: goprof.Arch(arch), + Path: goprof.ArtifactPath(path), + RelativePath: goprof.RelativePath(relative), + } +} + +// mustRoot parses a RootName and fails the test on error. +func mustRoot(t *testing.T, name string) goprof.RootName { + t.Helper() + root, err := goprof.ParseRootName(name) + require.NoError(t, err) + return root +} + +// writeExec creates an owner-executable file at path. +func writeExec(t *testing.T, path string, data []byte) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, data, 0o755)) + require.NoError(t, os.Chmod(path, 0o755)) +} + +func TestConfineRejectsDotDotOnDisk(t *testing.T) { + t.Parallel() + + root := t.TempDir() + outside := filepath.Join(filepath.Dir(root), "outside") + require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o755)) + t.Cleanup(func() { + _ = os.Remove(outside) + }) + + err := goprof.VerifyBinaries(os.DirFS(root), []goprof.CanonicalBinary{ + canonical("amd64", "dist/../outside", "../outside"), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes the dist root") +} diff --git a/internal/profile/goprof/doc.go b/internal/profile/goprof/doc.go new file mode 100644 index 0000000..a7d155e --- /dev/null +++ b/internal/profile/goprof/doc.go @@ -0,0 +1,8 @@ +// Package goprof selects canonical Linux binaries from GoReleaser artifacts.json. +// +// ParseArtifacts decodes the pinned GoReleaser record shape. SelectBinaries is +// a pure function that requires exactly one linux/amd64 Binary and one +// linux/arm64 Binary. Paths are relative to the --dist directory basename. +// VerifyBinaries checks that each selected path is lexically confined, a +// regular file, and owner-executable. +package goprof diff --git a/internal/profile/goprof/testdata/goreleaser-2.17.1-artifacts.json b/internal/profile/goprof/testdata/goreleaser-2.17.1-artifacts.json new file mode 100644 index 0000000..dad9a91 --- /dev/null +++ b/internal/profile/goprof/testdata/goreleaser-2.17.1-artifacts.json @@ -0,0 +1 @@ +[{"name":"metadata.json","path":"dist/metadata.json","internal_type":35,"type":"Metadata"},{"name":"release-cli","path":"dist/release-cli_darwin_arm64_v8.0/release-cli","goos":"darwin","goarch":"arm64","goarm64":"v8.0","target":"darwin_arm64_v8.0","internal_type":4,"type":"Binary","extra":{"Binary":"release-cli","Builder":"go","Ext":"","ID":"release-cli"}},{"name":"release-cli.exe","path":"dist/release-cli_windows_arm64_v8.0/release-cli.exe","goos":"windows","goarch":"arm64","goarm64":"v8.0","target":"windows_arm64_v8.0","internal_type":4,"type":"Binary","extra":{"Binary":"release-cli","Builder":"go","Ext":".exe","ID":"release-cli"}},{"name":"release-cli","path":"dist/release-cli_linux_amd64_v1/release-cli","goos":"linux","goarch":"amd64","goamd64":"v1","target":"linux_amd64_v1","internal_type":4,"type":"Binary","extra":{"Binary":"release-cli","Builder":"go","Ext":"","ID":"release-cli"}},{"name":"release-cli","path":"dist/release-cli_linux_arm64_v8.0/release-cli","goos":"linux","goarch":"arm64","goarm64":"v8.0","target":"linux_arm64_v8.0","internal_type":4,"type":"Binary","extra":{"Binary":"release-cli","Builder":"go","Ext":"","ID":"release-cli"}},{"name":"release-cli.exe","path":"dist/release-cli_windows_amd64_v1/release-cli.exe","goos":"windows","goarch":"amd64","goamd64":"v1","target":"windows_amd64_v1","internal_type":4,"type":"Binary","extra":{"Binary":"release-cli","Builder":"go","Ext":".exe","ID":"release-cli"}},{"name":"release-cli","path":"dist/release-cli_darwin_amd64_v1/release-cli","goos":"darwin","goarch":"amd64","goamd64":"v1","target":"darwin_amd64_v1","internal_type":4,"type":"Binary","extra":{"Binary":"release-cli","Builder":"go","Ext":"","ID":"release-cli"}}] \ No newline at end of file diff --git a/internal/stage/checksum.go b/internal/stage/checksum.go new file mode 100644 index 0000000..0eeeb5d --- /dev/null +++ b/internal/stage/checksum.go @@ -0,0 +1,272 @@ +package stage + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "slices" + "strings" + "unicode" +) + +// checksumLinePrefixLen is the SHA-256 hex digest plus the two-character +// GNU coreutils marker (" " or " *"). +const checksumLinePrefixLen = sha256.Size*2 + 2 + +// Control file names that live beside claimed payloads. +const ( + // checksumsName is the checksum claim filename. + checksumsName = "checksums.txt" + // bundleName is the Sigstore bundle that must accompany the claim. + bundleName = "checksums.txt.sigstore.json" + // artifactsName is the GoReleaser artifact inventory filename. + artifactsName = "artifacts.json" +) + +// Digest is a lowercase SHA-256 hex digest. +// +// The only constructor is [ParseDigest], which normalizes uppercase hex and +// rejects any other length or charset. The zero value is invalid. +type Digest string + +// AssetName is a flat payload filename from checksums.txt. +// +// The only constructor is [ParseAssetName], which rejects empty names and +// path separators. The zero value is invalid. +type AssetName string + +// ChecksumEntry is one validated claim from checksums.txt. +type ChecksumEntry struct { + // Name is the claimed payload filename. + Name AssetName + // Digest is the claimed SHA-256 digest. + Digest Digest +} + +// ChecksumSet is a validated checksums.txt claim. +// +// Values are produced only by [ParseChecksums]. The zero value has no +// entries and is rejected by [VerifyBundle]. +type ChecksumSet struct { + // entries is the ordered list of claimed payloads. + entries []ChecksumEntry +} + +// Report is the successful outcome of staging a Go dist bundle. +type Report struct { + // Assets is the number of checksummed payloads that matched. + Assets int + // Binaries are the selected canonical Linux binaries after verification. + Binaries []Binary +} + +// Binary is a verified canonical Linux binary observed on disk. +type Binary struct { + // Arch is the GOARCH of the selected binary. + Arch string + // Path is the original GoReleaser path, including the --dist basename prefix. + Path string + // Mode is the observed permission bits. + Mode fs.FileMode +} + +// ParseDigest constructs a [Digest] from a 64-digit hexadecimal string. +// +// Uppercase hex is normalized to lowercase. Any other length or charset is +// rejected. +func ParseDigest(raw string) (Digest, error) { + if len(raw) != hex.EncodedLen(sha256.Size) { + return "", fmt.Errorf("digest %q has length %d, want %d", raw, len(raw), hex.EncodedLen(sha256.Size)) + } + for _, r := range raw { + if !isHex(r) { + return "", fmt.Errorf("digest %q is not hexadecimal", raw) + } + } + + return Digest(strings.ToLower(raw)), nil +} + +// String returns the lowercase hex digest. +func (d Digest) String() string { + return string(d) +} + +// ParseAssetName constructs an [AssetName] from a flat checksums.txt filename. +// +// Names must be nonempty and must not contain a path separator. +func ParseAssetName(raw string) (AssetName, error) { + if raw == "" { + return "", errors.New("asset name is empty") + } + if strings.ContainsAny(raw, `/\`) { + return "", fmt.Errorf("asset name %q contains a path separator", raw) + } + + return AssetName(raw), nil +} + +// String returns the payload filename. +func (n AssetName) String() string { + return string(n) +} + +// Entries returns the claimed payloads in file order. +func (s ChecksumSet) Entries() []ChecksumEntry { + return slices.Clone(s.entries) +} + +// Len returns the number of claimed payloads. +func (s ChecksumSet) Len() int { + return len(s.entries) +} + +// ParseChecksums parses a GNU coreutils sha256sum claim. +// +// Accepted lines are `<64 hex>` or the binary-marker form +// `<64 hex>`. CRLF is tolerated. Uppercase hex is +// normalized. Empty input, duplicate names, path separators in names, +// malformed digests, and a self-listed checksums.txt are rejected. +func ParseChecksums(r io.Reader) (ChecksumSet, error) { + if r == nil { + return ChecksumSet{}, errors.New("checksums reader is nil") + } + + return parseChecksums(r) +} + +// parseChecksums parses after the exported nil check. +func parseChecksums(r io.Reader) (ChecksumSet, error) { + scanner := bufio.NewScanner(r) + seen := make(map[AssetName]struct{}) + var entries []ChecksumEntry + + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := strings.TrimSuffix(scanner.Text(), "\r") + entry, err := parseChecksumLine(line) + if err != nil { + return ChecksumSet{}, fmt.Errorf("checksums.txt line %d: %w", lineNumber, err) + } + if _, exists := seen[entry.Name]; exists { + return ChecksumSet{}, fmt.Errorf("duplicate checksums.txt entry: %s", entry.Name) + } + seen[entry.Name] = struct{}{} + entries = append(entries, entry) + } + if err := scanner.Err(); err != nil { + return ChecksumSet{}, fmt.Errorf("read checksums.txt: %w", err) + } + if len(entries) == 0 { + return ChecksumSet{}, errors.New("checksums.txt does not list any release payloads") + } + + return ChecksumSet{entries: entries}, nil +} + +// VerifyBundle streams every claimed payload through SHA-256 and requires a +// nonempty regular checksums.txt.sigstore.json. +// +// The first offending asset is named in the error. Payloads are hashed with +// [io.Copy] into [sha256.New]; they are never buffered whole. A nil +// filesystem is rejected. +func VerifyBundle(fsys fs.FS, claim ChecksumSet) error { + if fsys == nil { + return errors.New("filesystem is nil") + } + + return verifyBundle(fsys, claim) +} + +// verifyBundle verifies after the exported nil check. +func verifyBundle(fsys fs.FS, claim ChecksumSet) error { + for _, entry := range claim.entries { + if err := verifyPayload(fsys, entry); err != nil { + return err + } + } + + return requireRegularNonempty(fsys, bundleName) +} + +// parseChecksumLine validates one GNU sha256sum line. +func parseChecksumLine(line string) (ChecksumEntry, error) { + if len(line) < checksumLinePrefixLen+1 { + return ChecksumEntry{}, fmt.Errorf("malformed entry %q", line) + } + + digest, err := ParseDigest(line[:hex.EncodedLen(sha256.Size)]) + if err != nil { + return ChecksumEntry{}, err + } + + marker := line[hex.EncodedLen(sha256.Size):checksumLinePrefixLen] + if marker != " " && marker != " *" { + return ChecksumEntry{}, fmt.Errorf("malformed entry %q", line) + } + + name, err := ParseAssetName(line[checksumLinePrefixLen:]) + if err != nil { + return ChecksumEntry{}, err + } + if name.String() == checksumsName { + return ChecksumEntry{}, fmt.Errorf("control file %s must not be listed in checksums.txt", checksumsName) + } + + return ChecksumEntry{Name: name, Digest: digest}, nil +} + +// verifyPayload streams one claimed payload through SHA-256. +func verifyPayload(fsys fs.FS, entry ChecksumEntry) error { + name := entry.Name.String() + info, err := fs.Stat(fsys, name) + if err != nil { + return fmt.Errorf("release payload %s: %w", name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("release payload %s is not a regular file", name) + } + + file, err := fsys.Open(name) + if err != nil { + return fmt.Errorf("open %s: %w", name, err) + } + defer file.Close() + + sum := sha256.New() + if _, err := io.Copy(sum, file); err != nil { + return fmt.Errorf("hash %s: %w", name, err) + } + actual := Digest(hex.EncodeToString(sum.Sum(nil))) + if actual != entry.Digest { + return fmt.Errorf("release payload %s has digest %s, expected %s", name, actual, entry.Digest) + } + + return nil +} + +// requireRegularNonempty requires name to exist as a nonempty regular file. +func requireRegularNonempty(fsys fs.FS, name string) error { + info, err := fs.Stat(fsys, name) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", name) + } + if info.Size() == 0 { + return fmt.Errorf("%s is empty", name) + } + + return nil +} + +// isHex reports whether r is an ASCII hexadecimal digit. +func isHex(r rune) bool { + return unicode.Is(unicode.ASCII_Hex_Digit, r) +} diff --git a/internal/stage/checksum_test.go b/internal/stage/checksum_test.go new file mode 100644 index 0000000..b115e10 --- /dev/null +++ b/internal/stage/checksum_test.go @@ -0,0 +1,177 @@ +package stage_test + +import ( + "io/fs" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/stage" +) + +func TestParseChecksums(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []stage.ChecksumEntry + wantErr string + }{ + { + name: "two-space GNU form", + input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa archive.tar.gz\n", + want: []stage.ChecksumEntry{ + {Name: "archive.tar.gz", Digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + }, + }, + { + name: "binary marker", + input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa *archive.tar.gz\n", + want: []stage.ChecksumEntry{ + {Name: "archive.tar.gz", Digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + }, + }, + { + name: "uppercase hex is normalized", + input: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA archive.tar.gz\n", + want: []stage.ChecksumEntry{ + {Name: "archive.tar.gz", Digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + }, + }, + { + name: "CRLF is tolerated", + input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa archive.tar.gz\r\n", + want: []stage.ChecksumEntry{ + {Name: "archive.tar.gz", Digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + }, + }, + { + name: "empty input", + input: "", + wantErr: "does not list any release payloads", + }, + { + name: "duplicate name", + input: "" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa archive.tar.gz\n" + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb archive.tar.gz\n", + wantErr: "duplicate checksums.txt entry: archive.tar.gz", + }, + { + name: "self-listed control file", + input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa checksums.txt\n", + wantErr: "control file checksums.txt must not be listed", + }, + { + name: "path separator in name", + input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa nested/archive.tar.gz\n", + wantErr: "contains a path separator", + }, + { + name: "bad digest length", + input: "aaaa archive.tar.gz\n", + wantErr: "malformed entry", + }, + { + name: "bad digest charset", + input: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz archive.tar.gz\n", + wantErr: "is not hexadecimal", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := stage.ParseChecksums(strings.NewReader(test.input)) + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got.Entries()) + }) + } +} + +func TestVerifyBundle(t *testing.T) { + t.Parallel() + + const digest = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" + + claim, err := stage.ParseChecksums(strings.NewReader(digest + " payload.bin\n")) + require.NoError(t, err) + + tests := []struct { + name string + fsys fstest.MapFS + wantErr string + }{ + { + name: "matching regular payload and nonempty bundle", + fsys: fstest.MapFS{ + "payload.bin": {Data: []byte("foo")}, + "checksums.txt.sigstore.json": {Data: []byte("{bundle}")}, + }, + }, + { + name: "missing payload", + fsys: fstest.MapFS{ + "checksums.txt.sigstore.json": {Data: []byte("{bundle}")}, + }, + wantErr: "payload.bin", + }, + { + name: "empty payload hash mismatch", + fsys: fstest.MapFS{ + "payload.bin": {Data: []byte{}}, + "checksums.txt.sigstore.json": {Data: []byte("{bundle}")}, + }, + wantErr: "payload.bin", + }, + { + name: "non-regular payload", + fsys: fstest.MapFS{ + "payload.bin": {Mode: fs.ModeDir}, + "checksums.txt.sigstore.json": {Data: []byte("{bundle}")}, + }, + wantErr: "payload.bin is not a regular file", + }, + { + name: "missing sigstore bundle", + fsys: fstest.MapFS{ + "payload.bin": {Data: []byte("foo")}, + }, + wantErr: "checksums.txt.sigstore.json", + }, + { + name: "empty sigstore bundle", + fsys: fstest.MapFS{ + "payload.bin": {Data: []byte("foo")}, + "checksums.txt.sigstore.json": {Data: []byte{}}, + }, + wantErr: "checksums.txt.sigstore.json is empty", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := stage.VerifyBundle(test.fsys, claim) + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/stage/doc.go b/internal/stage/doc.go new file mode 100644 index 0000000..6959854 --- /dev/null +++ b/internal/stage/doc.go @@ -0,0 +1,7 @@ +// Package stage verifies a GoReleaser dist bundle against its checksum claim. +// +// ParseChecksums turns checksums.txt into a validated [ChecksumSet]. +// VerifyBundle streams each claimed payload through SHA-256 and requires a +// nonempty regular checksums.txt.sigstore.json. Callers supply [io/fs.FS]; +// the CLI composition edge is [os.OpenRoot]. +package stage diff --git a/internal/stage/stage.go b/internal/stage/stage.go new file mode 100644 index 0000000..ea7d7df --- /dev/null +++ b/internal/stage/stage.go @@ -0,0 +1,79 @@ +package stage + +import ( + "errors" + "fmt" + "io/fs" + + "github.com/meigma/release/internal/profile/goprof" +) + +// Stage verifies a Go profile dist directory whose basename is root. +// +// It parses checksums.txt, streams every claimed payload through SHA-256, +// requires a nonempty regular checksums.txt.sigstore.json, selects exactly +// one linux/amd64 and one linux/arm64 Binary from artifacts.json, and +// confirms each selected path is a confined regular executable. A nil +// filesystem is rejected. +func Stage(fsys fs.FS, root goprof.RootName) (Report, error) { + if fsys == nil { + return Report{}, errors.New("filesystem is nil") + } + + return stage(fsys, root) +} + +// stage verifies after the exported nil check. +func stage(fsys fs.FS, root goprof.RootName) (Report, error) { + checksums, err := fsys.Open(checksumsName) + if err != nil { + return Report{}, fmt.Errorf("open %s: %w", checksumsName, err) + } + claim, err := parseChecksums(checksums) + closeErr := checksums.Close() + if err != nil { + return Report{}, err + } + if closeErr != nil { + return Report{}, fmt.Errorf("close %s: %w", checksumsName, closeErr) + } + if err = verifyBundle(fsys, claim); err != nil { + return Report{}, err + } + + artifacts, err := fsys.Open(artifactsName) + if err != nil { + return Report{}, fmt.Errorf("open %s: %w", artifactsName, err) + } + records, err := goprof.ParseArtifacts(artifacts) + closeErr = artifacts.Close() + if err != nil { + return Report{}, err + } + if closeErr != nil { + return Report{}, fmt.Errorf("close %s: %w", artifactsName, closeErr) + } + + binaries, err := goprof.SelectBinaries(records, root) + if err != nil { + return Report{}, err + } + if err := goprof.VerifyBinaries(fsys, binaries); err != nil { + return Report{}, err + } + + report := Report{Assets: claim.Len()} + for _, binary := range binaries { + info, err := fs.Stat(fsys, binary.RelativePath.String()) + if err != nil { + return Report{}, fmt.Errorf("%s: %w", binary.Path, err) + } + report.Binaries = append(report.Binaries, Binary{ + Arch: binary.Arch.String(), + Path: binary.Path.String(), + Mode: info.Mode().Perm(), + }) + } + + return report, nil +} diff --git a/melange.yaml b/melange.yaml index 8f0b0b2..e6f79a3 100644 --- a/melange.yaml +++ b/melange.yaml @@ -1,5 +1,5 @@ package: - name: release-mvp + name: release-cli version: ${{vars.version}} epoch: 0 description: Exercise the Meigma release pipeline. @@ -23,4 +23,4 @@ environment: pipeline: - runs: | - install -Dm755 -o 0 -g 0 application "${{targets.destdir}}/usr/bin/release-mvp" + install -Dm755 -o 0 -g 0 application "${{targets.destdir}}/usr/bin/release-cli" diff --git a/moon.yml b/moon.yml index 885675a..684d0ec 100644 --- a/moon.yml +++ b/moon.yml @@ -3,7 +3,7 @@ layer: 'application' stack: 'backend' project: - title: 'release-mvp' + title: 'release-cli' description: 'Exercise the Meigma release pipeline.' owner: 'meigma' maintainers: @@ -52,12 +52,21 @@ tasks: cache: false build: - command: 'mise exec -- go build -o bin/release-mvp ./cmd/release-mvp' + command: 'mise exec -- go build -o bin/release-cli ./cmd/release-cli' inputs: - '@group(goSources)' - '@group(ciConfig)' outputs: - - 'bin/release-mvp' + - 'bin/release-cli' + + protocol: + command: 'mise exec -- bash scripts/check-protocol-stamp.sh' + inputs: + - 'scripts/check-protocol-stamp.sh' + - '.github/actions/setup-release-cli/action.yml' + - '@group(goSources)' + options: + cache: false test: command: 'mise exec -- go test ./...' @@ -73,6 +82,7 @@ tasks: - 'root:lint' - 'root:build' - 'root:test' + - 'root:protocol' inputs: - '@group(goSources)' - '@group(lintConfig)' diff --git a/release-please-config.json b/release-please-config.json index 942b03b..15e0f87 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -10,8 +10,14 @@ "bump-patch-for-minor-pre-major": true, "packages": { ".": { - "package-name": "release-mvp", - "changelog-path": "CHANGELOG.md" + "package-name": "release-cli", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "generic", + "path": ".github/actions/setup-release-cli/action.yml" + } + ] } }, "changelog-sections": [ diff --git a/scripts/check-protocol-stamp.sh b/scripts/check-protocol-stamp.sh new file mode 100755 index 0000000..69f1266 --- /dev/null +++ b/scripts/check-protocol-stamp.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# Guard the two hand-moved release-unit stamps that Release Please does +# not keep in lockstep automatically. +# +# Release Please stamps DEFAULT_VERSION only while the annotated +# x-release-please-start-version / x-release-please-end markers remain +# around that line. Deleting the markers leaves DEFAULT_VERSION stuck +# and every installed acquisition path downloads the wrong tag. +# +# The protocol integer is a source literal in both the action +# (EXPECTED_PROTOCOL) and Go (Protocol); extra-files stamps the version +# only, so this check is the CI guard that the two protocol literals +# stay equal. +# + +set -euo pipefail + +root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +action="${root}/.github/actions/setup-release-cli/action.yml" +cli_dir="${root}/internal/cli" + +die() { + printf '%s\n' "$*" >&2 + exit 1 +} + +# matching_lines prints 1-based line numbers of lines in FILE matching PATTERN. +matching_lines() { + # grep exits 1 when there are no matches; keep going. + grep -n -E "$1" "$2" | sed -n 's/^\([0-9][0-9]*\):.*/\1/p' || true +} + +# count_lines returns the number of newline-delimited items in TEXT. +count_lines() { + if [ -z "${1:-}" ]; then + printf '0\n' + return 0 + fi + printf '%s\n' "$1" | grep -c . +} + +if [ ! -f "$action" ]; then + die "check-protocol-stamp: missing ${action}" +fi + +if [ ! -d "$cli_dir" ]; then + die "check-protocol-stamp: missing ${cli_dir}" +fi + +start_lines="$(matching_lines '^[[:space:]]*#[[:space:]]*x-release-please-start-version([[:space:]]|$)' "$action")" +end_lines="$(matching_lines '^[[:space:]]*#[[:space:]]*x-release-please-end([[:space:]]|$)' "$action")" +start_count="$(count_lines "$start_lines")" +end_count="$(count_lines "$end_lines")" + +if [ "$start_count" -eq 0 ]; then + die "check-protocol-stamp: missing # x-release-please-start-version in ${action}" +fi +if [ "$end_count" -eq 0 ]; then + die "check-protocol-stamp: missing # x-release-please-end in ${action}" +fi +if [ "$start_count" -ne 1 ]; then + die "check-protocol-stamp: expected exactly one # x-release-please-start-version in ${action}, found ${start_count}" +fi +if [ "$end_count" -ne 1 ]; then + die "check-protocol-stamp: expected exactly one # x-release-please-end in ${action}, found ${end_count}" +fi + +start_line="$start_lines" +end_line="$end_lines" +if [ "$start_line" -ge "$end_line" ]; then + die "check-protocol-stamp: # x-release-please-start-version (line ${start_line}) must precede # x-release-please-end (line ${end_line}) in ${action}" +fi + +version_lines="$(matching_lines '^[[:space:]]*DEFAULT_VERSION:' "$action")" +enclosed="" +if [ -n "$version_lines" ]; then + while IFS= read -r lineno; do + if [ "$lineno" -gt "$start_line" ] && [ "$lineno" -lt "$end_line" ]; then + enclosed="${enclosed}${enclosed:+ +}${lineno}" + fi + done <