From 88e36d6ae35406b6615a674706225cf62c93a09d Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 20:02:31 -0700 Subject: [PATCH] feat(cli): verify Actions artifact handoffs Replace the three actions/github-script artifact-metadata blocks (OB-06, GR-05, OP-03) with `release-cli verify handoff`. Each caller now validates the metadata tuple through the GitHub API before downloading: the artifact must exist, belong to the current run, be unexpired, and report the digest the caller expects. This introduces the program's first genuine port and adapter. `ArtifactMeta` is declared in the consuming pubgh package and implemented by ghact over go-github v82, with a Mockery-generated mock under the adapter. A pinned Mockery 3.7.3 plus scripts/check-mocks.sh keeps checked-in mocks byte-identical to the generator, and both are wired into `moon run root:check`. Bounded retry lives in the engine with an injected sleep, deliberately matching the octokit `retries: 3` semantics it replaces: four attempts total with 1s, 2s, and 4s waits, retrying only rate-limit and 5xx responses, never absent, auth, or malformed ones, and returning immediately on cancellation. The three-owner handoff split is preserved and stated explicitly: the CLI checks the API tuple, the SHA-pinned download-artifact step still owns the ZIP transport digest, and later commands verify extracted content. The CLI never claims to recompute the transport digest. GITHUB_API_URL and GITHUB_SERVER_URL are honored so Enterprise Server consumers work and the composed command is testable against a local stub. Missing or malformed configuration exits 2 before any network call; a tuple mismatch exits 1. Tokens never enter domain values, outputs, or wrapped errors. Reviewed over two rounds and audited against AGENTS.md. The Actions output port was dropped from this slice for lack of a production caller, and the sentinel set was reduced to the two with real consumers. --- .github/workflows/go-oci-build.yml | 69 +-- .github/workflows/publish-github-release.yml | 68 +-- .github/workflows/publish-oci-image.yml | 68 +-- .github/workflows/release.yml | 3 + .github/workflows/verify-setup-installed.yml | 77 +++ .mockery.yml | 25 + cmd/release-cli/main.go | 5 + .../upgrade-github-release-workflows.md | 2 + docs/reference/github-release-contract.md | 23 +- docs/reference/oci-image-contract.md | 30 +- docs/reference/release-cli-contract.md | 70 ++- go.mod | 3 + go.sum | 9 + internal/adapter/ghact/client.go | 164 ++++++ internal/adapter/ghact/client_test.go | 255 +++++++++ internal/adapter/ghact/doc.go | 8 + internal/adapter/ghact/mocks/artifact_meta.go | 112 ++++ internal/adapter/ghact/mocks/doc.go | 5 + internal/cli/doc.go | 8 +- internal/cli/handoff.go | 285 ++++++++++ internal/cli/handoff_test.go | 458 ++++++++++++++++ internal/cli/result.go | 29 +- internal/cli/root.go | 21 +- internal/stage/pubgh/doc.go | 7 + internal/stage/pubgh/handoff.go | 432 +++++++++++++++ internal/stage/pubgh/handoff_test.go | 495 ++++++++++++++++++ mise.lock | 20 + mise.toml | 1 + moon.yml | 15 + scripts/check-mocks.sh | 85 +++ 30 files changed, 2734 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/verify-setup-installed.yml create mode 100644 .mockery.yml create mode 100644 internal/adapter/ghact/client.go create mode 100644 internal/adapter/ghact/client_test.go create mode 100644 internal/adapter/ghact/doc.go create mode 100644 internal/adapter/ghact/mocks/artifact_meta.go create mode 100644 internal/adapter/ghact/mocks/doc.go create mode 100644 internal/cli/handoff.go create mode 100644 internal/cli/handoff_test.go create mode 100644 internal/stage/pubgh/doc.go create mode 100644 internal/stage/pubgh/handoff.go create mode 100644 internal/stage/pubgh/handoff_test.go create mode 100755 scripts/check-mocks.sh diff --git a/.github/workflows/go-oci-build.yml b/.github/workflows/go-oci-build.yml index 4171022..98c4b93 100644 --- a/.github/workflows/go-oci-build.yml +++ b/.github/workflows/go-oci-build.yml @@ -21,6 +21,14 @@ on: required: false default: apko.yaml type: string + 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 OCI image artifact. @@ -49,6 +57,7 @@ jobs: image-digest: ${{ steps.verify.outputs.image-digest }} permissions: actions: read + attestations: read contents: read env: MISE_EXEC_AUTO_INSTALL: 'false' @@ -103,42 +112,38 @@ jobs: apko version ' + - 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: | + 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 binary handoff - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} ARTIFACT_ID: ${{ inputs.artifact-id }} EXPECTED_DIGEST: ${{ inputs.artifact-digest }} - with: - retries: 3 - script: | - const artifactId = Number(process.env.ARTIFACT_ID) - if (!Number.isSafeInteger(artifactId) || artifactId <= 0) { - throw new Error(`Invalid artifact ID: ${process.env.ARTIFACT_ID}`) - } - - const {data: artifact} = await github.rest.actions.getArtifact({ - ...context.repo, - artifact_id: artifactId, - }) - if (artifact.expired) { - throw new Error(`Artifact ${artifactId} has expired.`) - } - if (artifact.workflow_run?.id !== Number(context.runId)) { - throw new Error( - `Artifact ${artifactId} belongs to workflow run ` + - `${artifact.workflow_run?.id}, expected ${context.runId}.`, - ) - } - const normalize = (digest) => String(digest ?? '') - .replace(/^sha256:/, '') - .toLowerCase() - const expected = normalize(process.env.EXPECTED_DIGEST) - const actual = normalize(artifact.digest) - if (!expected || actual !== expected) { - throw new Error( - `Artifact digest mismatch: expected ${process.env.EXPECTED_DIGEST}, got ${artifact.digest}.`, - ) - } + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + "${RELEASE_CLI}" verify handoff --artifact-id "${ARTIFACT_ID}" --digest "${EXPECTED_DIGEST}" - name: Download canonical Linux binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 6594864..4f665fc 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -34,6 +34,14 @@ on: required: false default: '' type: string + 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: '' + secrets: release-app-private-key: description: Private key of the GitHub App that publishes releases. @@ -126,42 +134,38 @@ jobs: private-key: ${{ secrets.release-app-private-key }} permission-contents: write + - 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: | + 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 artifact handoff - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} ARTIFACT_ID: ${{ inputs.artifact-id }} EXPECTED_DIGEST: ${{ inputs.artifact-digest }} - with: - retries: 3 - script: | - const artifactId = Number(process.env.ARTIFACT_ID) - if (!Number.isSafeInteger(artifactId) || artifactId <= 0) { - throw new Error(`Invalid artifact ID: ${process.env.ARTIFACT_ID}`) - } - - const {data: artifact} = await github.rest.actions.getArtifact({ - ...context.repo, - artifact_id: artifactId, - }) - if (artifact.expired) { - throw new Error(`Artifact ${artifactId} has expired.`) - } - if (artifact.workflow_run?.id !== Number(context.runId)) { - throw new Error( - `Artifact ${artifactId} belongs to workflow run ` + - `${artifact.workflow_run?.id}, expected ${context.runId}.`, - ) - } - const normalize = (digest) => String(digest ?? '') - .replace(/^sha256:/, '') - .toLowerCase() - const expected = normalize(process.env.EXPECTED_DIGEST) - const actual = normalize(artifact.digest) - if (!expected || actual !== expected) { - throw new Error( - `Artifact digest mismatch: expected ${process.env.EXPECTED_DIGEST}, got ${artifact.digest}.`, - ) - } + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + "${RELEASE_CLI}" verify handoff --artifact-id "${ARTIFACT_ID}" --digest "${EXPECTED_DIGEST}" - name: Wait for matching draft release id: draft diff --git a/.github/workflows/publish-oci-image.yml b/.github/workflows/publish-oci-image.yml index 691f4cd..6535a5b 100644 --- a/.github/workflows/publish-oci-image.yml +++ b/.github/workflows/publish-oci-image.yml @@ -20,6 +20,14 @@ on: required: false default: false type: boolean + 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: image-name: description: Canonical GHCR image name. @@ -85,42 +93,38 @@ jobs: install_args: aqua:oras-project/oras aqua:sigstore/cosign cache: true + - 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: | + 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 authoritative OCI artifact - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} ARTIFACT_ID: ${{ inputs.artifact-id }} EXPECTED_DIGEST: ${{ inputs.artifact-digest }} - with: - retries: 3 - script: | - const artifactId = Number(process.env.ARTIFACT_ID) - if (!Number.isSafeInteger(artifactId) || artifactId <= 0) { - throw new Error(`Invalid artifact ID: ${process.env.ARTIFACT_ID}`) - } - - const {data: artifact} = await github.rest.actions.getArtifact({ - ...context.repo, - artifact_id: artifactId, - }) - if (artifact.expired) { - throw new Error(`Artifact ${artifactId} has expired.`) - } - if (artifact.workflow_run?.id !== Number(context.runId)) { - throw new Error( - `Artifact ${artifactId} belongs to workflow run ` + - `${artifact.workflow_run?.id}, expected ${context.runId}.`, - ) - } - const normalize = (digest) => String(digest ?? '') - .replace(/^sha256:/, '') - .toLowerCase() - const expected = normalize(process.env.EXPECTED_DIGEST) - const actual = normalize(artifact.digest) - if (!expected || actual !== expected) { - throw new Error( - `Artifact digest mismatch: expected ${process.env.EXPECTED_DIGEST}, got ${artifact.digest}.`, - ) - } + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + "${RELEASE_CLI}" verify handoff --artifact-id "${ARTIFACT_ID}" --digest "${EXPECTED_DIGEST}" - name: Download authoritative OCI image uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87eebd2..1427ea8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,7 @@ jobs: with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} + cli-path: .release-cli/release-cli oci-publish: name: Publish OCI image needs: oci-image @@ -96,6 +97,7 @@ jobs: artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} image-digest: ${{ needs.oci-image.outputs.image-digest }} publish-image: true + cli-path: .release-cli/release-cli @@ -120,5 +122,6 @@ jobs: oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: true + cli-path: .release-cli/release-cli secrets: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/verify-setup-installed.yml b/.github/workflows/verify-setup-installed.yml new file mode 100644 index 0000000..351fc33 --- /dev/null +++ b/.github/workflows/verify-setup-installed.yml @@ -0,0 +1,77 @@ +# Exercises the composite's installed acquisition path end to end: +# archive download, checksum verification, gh attestation verify with +# the derived --signer-workflow and --deny-self-hosted-runners, then +# stamp equality. +# +# This cannot pass until a release exists (release-please PR #6, +# chore(main): release 0.1.0, is still open). It is workflow_dispatch +# only and is intentionally not a merge gate. +name: Verify setup-release-cli installed path + +on: + workflow_dispatch: + inputs: + version: + description: Semantic version the installed CLI must report (without a v prefix). + required: true + type: string + +permissions: {} + +jobs: + verify-installed: + name: Verify installed release-cli + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + attestations: read + contents: read + env: + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + mise_toml: | + [tools] + "aqua:cli/cli" = "2.97.0" + install_args: aqua:cli/cli + cache: true + add_shims_to_path: true + export_path: true + + - name: Set up release-cli + id: setup-cli + uses: $/.github/actions/setup-release-cli + + - name: Assert installed version and protocol + env: + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} + EXPECTED_VERSION: ${{ inputs.version }} + COMPOSITE_VERSION: ${{ steps.setup-cli.outputs.reported-version }} + COMPOSITE_PROTOCOL: ${{ steps.setup-cli.outputs.reported-protocol }} + shell: bash + run: | + set -euo pipefail + report="$("${RELEASE_CLI}" version --json)" + printf '%s\n' "${report}" + reported_version="$(printf '%s' "${report}" | jq -r .result.version)" + reported_protocol="$(printf '%s' "${report}" | jq -r .result.protocol)" + if [[ "${reported_version}" != "${EXPECTED_VERSION}" ]]; then + echo "::error::Installed CLI reported version ${reported_version}, expected ${EXPECTED_VERSION}." + exit 1 + fi + if [[ "${reported_protocol}" != "1" ]]; then + echo "::error::Installed CLI reported protocol ${reported_protocol}, expected 1." + exit 1 + fi + if [[ "${reported_version}" != "${COMPOSITE_VERSION}" ]]; then + echo "::error::version --json does not match the composite reported-version output." + exit 1 + fi + if [[ "${reported_protocol}" != "${COMPOSITE_PROTOCOL}" ]]; then + echo "::error::version --json does not match the composite reported-protocol output." + exit 1 + fi diff --git a/.mockery.yml b/.mockery.yml new file mode 100644 index 0000000..075ccc6 --- /dev/null +++ b/.mockery.yml @@ -0,0 +1,25 @@ +# Interfaces live in the consumer package (I2). Generated mocks live under +# the implementing adapter's mocks/ package (T3), not next to the interface. +# Add an interface here only when its slice lands. The port budget is closed +# at 13; do not invent a fourteenth. +all: false +dir: '{{.InterfaceDir}}' +filename: '{{ .InterfaceName | snakecase }}.go' +force-file-write: true +formatter: goimports +formatter-options: + goimports: + local-prefix: github.com/meigma/release +log-level: info +structname: '{{.Mock}}{{.InterfaceName}}' +pkgname: mocks +recursive: false +template: testify +packages: + github.com/meigma/release/internal/stage/pubgh: + config: + dir: internal/adapter/ghact/mocks + interfaces: + ArtifactMeta: + config: + filename: artifact_meta.go diff --git a/cmd/release-cli/main.go b/cmd/release-cli/main.go index 5bd6940..ef3feab 100644 --- a/cmd/release-cli/main.go +++ b/cmd/release-cli/main.go @@ -7,7 +7,9 @@ import ( "os/signal" "syscall" + "github.com/meigma/release/internal/adapter/ghact" "github.com/meigma/release/internal/cli" + "github.com/meigma/release/internal/stage/pubgh" ) //nolint:gochecknoglobals // Linker-injected build metadata. @@ -30,6 +32,9 @@ func run() int { In: os.Stdin, Out: os.Stdout, Err: os.Stderr, + NewArtifactMeta: func(token string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { + return ghact.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) + }, Build: cli.BuildInfo{ Version: version, Commit: commit, diff --git a/docs/how-to/upgrade-github-release-workflows.md b/docs/how-to/upgrade-github-release-workflows.md index b5a0480..b4af36b 100644 --- a/docs/how-to/upgrade-github-release-workflows.md +++ b/docs/how-to/upgrade-github-release-workflows.md @@ -86,6 +86,8 @@ Stop if the target revision removes a required consumer capability or if any mig ## 2. Apply the target contract atomically +Moving from the preceding revision to the artifact-handoff verification revision requires no consumer interface changes beyond updating the pinned revision. Every existing reusable workflow input and output is unchanged. `go-oci-build.yml`, `publish-github-release.yml`, and `publish-oci-image.yml` add only the optional `cli-path` input, whose default is empty. Normal consumers omit `cli-path`; the workflows then install the CLI associated with the pinned release revision. + In `.github/workflows/release.yml`, replace the current revision with `NEW_RELEASE_REVISION` in all five locations: 1. `uses: meigma/release/.github/workflows/go-pre-publish.yml@...` diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md index 30a0ed1..663f993 100644 --- a/docs/reference/github-release-contract.md +++ b/docs/reference/github-release-contract.md @@ -143,6 +143,7 @@ The workflow runs on `ubuntu-24.04` with a 20-minute timeout. It declares `permi | --- | --- | --- | --- | --- | | `artifact-id` | string | Yes | None | Positive integer ID from `go-pre-publish.yml`. | | `artifact-digest` | string | Yes | None | Expected SHA-256 digest from `go-pre-publish.yml`. The comparison accepts the digest with or without a `sha256:` prefix. | +| `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. | | `checksum-signing-workflow-ref` | string | Yes | None | Exact owner, repository, workflow path, and revision used as the checksum certificate identity after the `https://github.com/` prefix is added. | | `release-app-client-id` | string | Yes | None | Client ID used to mint the Release App installation token. | | `publish-release` | boolean | No | `true` | Whether to change the populated draft to a non-draft release after verification. | @@ -170,6 +171,14 @@ The publisher job requires these caller permissions: The workflow runs on `ubuntu-24.04` with a 10-minute timeout. The reusable workflow declares `permissions: {}` at workflow scope; the caller must grant the job permissions explicitly. +After the tag gate, checkout, tool setup, and Release App token step, the publisher's relevant sequence is: + +1. If `cli-path` is nonempty, place the same-run dogfood binary at that path. +2. Run `setup-release-cli` from the same pinned release revision. +3. Run `release-cli verify handoff --artifact-id --digest `. +4. Find the matching draft release and verify its tag. +5. Download the artifact with the SHA-pinned `actions/download-artifact` step and `digest-mismatch: error`. + ## Versioning and credentials The current versioning workflow runs Release Please on pushes to `main` and on `workflow_dispatch`. It declares `permissions: {}` at workflow scope. Its job declares `contents: write`, `pull-requests: write`, and `issues: write`, then passes a Release App installation token to `googleapis/release-please-action`. @@ -284,7 +293,15 @@ 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 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`. +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's artifact handoff has three independent owners: + +1. `release-cli verify handoff` verifies the GitHub API metadata tuple before download: the artifact exists, belongs to the current workflow run, has not expired, and has a GitHub-reported digest that matches the caller-supplied digest after normalization. +2. The SHA-pinned `actions/download-artifact` step, configured with `digest-mismatch: error`, verifies the transport digest of the artifact ZIP. +3. Later `release-cli` content commands verify the extracted content. + +`release-cli verify handoff` does not download the artifact and never reproduces the Actions ZIP digest. `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: @@ -335,7 +352,9 @@ The publisher does not create a release, generate release notes, change a tag, o ## Retry and recovery behavior -The artifact verification, draft lookup, upload, and final verification steps request three retries from `actions/github-script` for retryable API failures. Draft discovery additionally polls up to 24 times at five-second intervals. Final asset inspection polls up to 12 times at one-second intervals for every expected asset to report an `uploaded` state and a digest. +`verify handoff` uses the [`release-cli` metadata request retry policy](release-cli-contract.md#metadata-request-retries). That policy preserves the `retries: 3` behavior of the replaced artifact metadata block. + +The remaining draft lookup, upload, and final verification steps request three retries from `actions/github-script` for retryable API failures. Draft discovery additionally polls up to 24 times at five-second intervals. Final asset inspection polls up to 12 times at one-second intervals for every expected asset to report an `uploaded` state and a digest. A failed run does not roll back uploaded assets or delete the draft. Recovery is convergent while the release remains a draft: diff --git a/docs/reference/oci-image-contract.md b/docs/reference/oci-image-contract.md index f367971..91f04a2 100644 --- a/docs/reference/oci-image-contract.md +++ b/docs/reference/oci-image-contract.md @@ -43,6 +43,7 @@ Moving branches and tags are not supported workflow references. | --- | --- | --- | --- | | `artifact-id` | yes | none | Numeric ID of the canonical Linux binary artifact from `go-pre-publish.yml`. | | `artifact-digest` | yes | none | GitHub artifact SHA-256 digest for that artifact. | +| `cli-path` | no | empty | Unsupported path to a caller-supplied `release-cli` binary. The caller owns the workflow-to-binary pairing. Normal consumers omit this input. | | `melange-config` | no | `melange.yaml` | Consumer-relative Melange configuration path. | | `apko-config` | no | `apko.yaml` | Consumer-relative apko configuration path. | @@ -57,6 +58,13 @@ It returns: The caller grants `actions: read` and `contents: read`. The builder has no registry credentials, package write permission, attestation permission, or release credential. +After the tag gate, checkout, mise, QEMU, and tool proof, the builder's relevant sequence is: + +1. If `cli-path` is nonempty, place the same-run dogfood binary at that path. +2. Run `setup-release-cli`. +3. Run `release-cli verify handoff --artifact-id --digest `. +4. Download the canonical Linux binaries with the SHA-pinned `actions/download-artifact` step and `digest-mismatch: error`. + ## Publisher interface `publish-oci-image.yml` accepts these inputs: @@ -65,6 +73,7 @@ The caller grants `actions: read` and `contents: read`. The builder has no regis | --- | --- | --- | --- | | `artifact-id` | yes | none | Numeric ID of the `oci-image` artifact from `go-oci-build.yml`. | | `artifact-digest` | yes | none | Expected GitHub artifact SHA-256 digest. | +| `cli-path` | no | empty | Unsupported path to a caller-supplied `release-cli` binary. The caller owns the workflow-to-binary pairing. Normal consumers omit this input. | | `image-digest` | yes | none | Expected OCI index digest. | | `publish-image` | no | `false` | When `true`, push, sign, and attest the verified image. When `false`, stop after verification. | @@ -93,6 +102,13 @@ permissions: `packages: write` authenticates ORAS, Cosign, and registry-backed GitHub attestations to GHCR. `id-token: write` supplies short-lived Sigstore identity. The publisher receives no GitHub App key and cannot mutate repository contents or releases. +After the stable-tag gate and tool setup, the publisher's relevant sequence is: + +1. If `cli-path` is nonempty, place the same-run dogfood binary at that path. +2. Run `setup-release-cli`. +3. Run `release-cli verify handoff --artifact-id --digest `. +4. Download the authoritative OCI image with the SHA-pinned `actions/download-artifact` step and `digest-mismatch: error`. + ## Consumer configuration ### GoReleaser @@ -152,13 +168,15 @@ sboms/sbom-x86_64.spdx.json The package directories also contain signed APK repository indexes, Melange provenance, embedded package SBOMs, and the signed APKs. Files not listed above may be diagnostic outputs from the pinned tools; consumers must not infer a stable API from undocumented filenames. -The publisher verifies all three handoff coordinates before a registry write: +Artifact handoff integrity has three independent owners: + +1. `release-cli verify handoff` verifies the GitHub API metadata tuple before download: the artifact exists, belongs to the current workflow run, has not expired, and has a GitHub-reported digest that matches the caller-supplied digest after normalization. +2. The SHA-pinned `actions/download-artifact` step, configured with `digest-mismatch: error`, verifies the transport digest of the artifact ZIP. +3. Later `release-cli` content commands verify the extracted content. For the OCI image artifact, content verification includes requiring the recorded, recomputed, and caller-supplied OCI index digests to be identical, one Linux manifest for `amd64`, one for `arm64`, all referenced blobs, and parseable SPDX JSON for each architecture. -1. artifact ID belongs to the current workflow run; -2. GitHub artifact digest equals the caller-supplied digest; and -3. recorded, recomputed, and caller-supplied OCI index digests are identical. +`release-cli verify handoff` does not download the artifact and never reproduces the Actions ZIP digest. -It also requires one Linux manifest for `amd64`, one for `arm64`, both referenced blobs, and parseable SPDX JSON for each architecture. +Both OCI workflows use the [`release-cli` metadata request retry policy](release-cli-contract.md#metadata-request-retries) for `verify handoff`. That policy preserves the `retries: 3` behavior of each replaced artifact metadata block. ## Published image @@ -254,7 +272,7 @@ The current boundary is deliberately split: - `publish-oci-image.yml` does not check out consumer source and writes only to the caller's GHCR package and attestation store; and - `publish-github-release.yml` waits for image publication but uses a separate short-lived Release App token for release mutation. -The privileged publisher implements validation and orchestration with the pinned `actions/github-script` action. It invokes ORAS and Cosign with explicit argument arrays through `@actions/exec`; release metadata is not interpolated into shell programs. +The privileged publisher uses `release-cli` for pre-download artifact metadata verification. The remaining validation and orchestration stay in the pinned workflow actions. ORAS and Cosign receive explicit argument arrays through `@actions/exec`; release metadata is not interpolated into shell programs. The workflow artifact is temporary transport, not a public distribution channel. The OCI digest, registry content, Cosign identity, and attestation identities form the public verification boundary. diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index f4f2ba4..ab8c31f 100644 --- a/docs/reference/release-cli-contract.md +++ b/docs/reference/release-cli-contract.md @@ -7,9 +7,12 @@ | Command | Purpose | | --- | --- | | `release-cli stage --profile go --dist PATH [--json]` | Validate the staged Go release files under `PATH`. | +| `release-cli verify handoff --artifact-id --digest [--json]` | Verify an Actions artifact's GitHub API metadata before download. | | `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. | -`--dist` is required for `stage`. The only accepted profile is `go`. +`--dist` is required for `stage`. The only accepted profile is `go`. `verify handoff` requires artifact ID and digest values. Supply them with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable. + +The artifact ID must be a positive decimal safe integer. The digest must be a 64-digit hexadecimal SHA-256 value with or without the `sha256:` prefix. Digest hex is case-insensitive and is normalized to lowercase with the prefix. ## JSON output @@ -22,7 +25,7 @@ When option and argument parsing succeeds and `--json` is requested, stdout cont | Field | Value | | --- | --- | | `schema` | Always `release.dev/result/v1`. | -| `command` | The command path, such as `stage` or `version`. | +| `command` | The command path, such as `stage`, `verify handoff`, or `version`. | | `ok` | `true` when the command succeeds; otherwise `false`. | | `result` | The command-specific result object. | @@ -43,6 +46,30 @@ The `version --json` result contains exactly these fields: | `commit` | string | Source commit stamped into the binary. | | `protocol` | number | Protocol integer compiled into the CLI. The current value is `1`. | +The `verify handoff --json` result contains this object: + +```json +{ + "artifact": { + "id": 11, + "name": "release-assets", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size_bytes": 42, + "run_id": 100, + "expires_at": "2026-08-19T15:04:05Z" + } +} +``` + +| Field | JSON type | Value | +| --- | --- | --- | +| `artifact.id` | number | Positive Actions artifact ID returned by GitHub. | +| `artifact.name` | string | Artifact name reported by GitHub. | +| `artifact.digest` | string | Normalized, lowercase GitHub-reported digest with the `sha256:` prefix. | +| `artifact.size_bytes` | number | Artifact size in bytes reported by GitHub. | +| `artifact.run_id` | number | Workflow run ID associated with the artifact. | +| `artifact.expires_at` | string | Artifact expiration time in RFC 3339 format, or an empty string if GitHub omitted it. | + 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 @@ -50,7 +77,7 @@ 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. +Without `--json`, a successful `stage` or `verify handoff` 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 all commands. ## Exit codes @@ -62,6 +89,43 @@ Without `--json`, a successful `stage` command writes nothing to stdout. A succe 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. +## Actions artifact handoff + +`verify handoff` reads the artifact metadata from the GitHub Actions API before any artifact download. It validates all of these conditions: + +- the artifact exists; +- the artifact belongs to the current workflow run; +- the artifact has not expired; and +- the artifact's GitHub-reported digest matches `--digest` after digest normalization. + +The command obtains its Actions context from these environment variables: + +| Variable | Value | +| --- | --- | +| `GITHUB_REPOSITORY` | Repository in `owner/name` form. | +| `GITHUB_RUN_ID` | Positive workflow run ID. | +| `GITHUB_TOKEN` or `GH_TOKEN` | Token used to authenticate the GitHub API client. A nonempty `GITHUB_TOKEN` takes precedence; `GH_TOKEN` is the fallback. | +| `GITHUB_API_URL` | Optional absolute GitHub API base URL. If it is unset or identifies `api.github.com`, the command uses the public GitHub client. A custom URL selects that API endpoint. | +| `GITHUB_SERVER_URL` | Optional absolute GitHub server and upload base URL used with a custom `GITHUB_API_URL`. If omitted for a custom API, the API URL is also used as the upload base. | + +A missing or malformed artifact input or Actions environment is a configuration failure. The command exits with code `2` before making a network request. An absent artifact, missing workflow-run metadata, wrong workflow run, expired artifact, or digest mismatch is a verification failure and exits with code `1`. + +### Metadata request retries + +`verify handoff` makes at most four metadata requests: one initial request and up to three retries. After successive retryable failures, it waits 1 second, 2 seconds, and 4 seconds before the next request. + +The command retries only transient failures: GitHub rate limiting and HTTP `5xx` responses. It never retries an absent artifact, an authentication failure, or a malformed response. If the context is canceled before or during a request, or during a retry wait, the command returns immediately without another request. + +This policy deliberately matches the `retries: 3` behavior of the three `actions/github-script` metadata blocks that `verify handoff` replaces. + +Artifact handoff integrity has three owners: + +1. `release-cli verify handoff` verifies the GitHub API metadata tuple before download. +2. The SHA-pinned `actions/download-artifact` step, configured with `digest-mismatch: error`, verifies the transport digest of the artifact ZIP. +3. Later `release-cli` content commands verify the extracted content. + +`release-cli` does not download the artifact and never reproduces the Actions ZIP digest. + ## Go staging profile `stage --profile go` validates the files already written under the distribution directory. It performs these checks: diff --git a/go.mod b/go.mod index ecce2c8..ee4480c 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,19 @@ module github.com/meigma/release go 1.26.6 require ( + github.com/google/go-github/v82 v82.0.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-querystring v1.2.0 // 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.10 // indirect + github.com/stretchr/objx v0.5.2 // 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 5290212..39c2284 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,13 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N 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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= +github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= 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= @@ -19,6 +26,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT 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/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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= diff --git a/internal/adapter/ghact/client.go b/internal/adapter/ghact/client.go new file mode 100644 index 0000000..d5079dd --- /dev/null +++ b/internal/adapter/ghact/client.go @@ -0,0 +1,164 @@ +package ghact + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/stage/pubgh" +) + +// Client fetches Actions artifact metadata through go-github. +type Client struct { + // github is the already-authenticated API client. + github *github.Client +} + +// New constructs a [Client] around an already-authenticated go-github client. +// +// The caller supplies authentication. Token text must not be stored on +// [Client] or included in returned errors. +func New(client *github.Client) *Client { + return &Client{github: client} +} + +// NewAuthenticated constructs a [Client] for token at the given GitHub API. +// +// An empty apiURL selects the public https://api.github.com client. A +// non-empty apiURL uses [github.Client.WithEnterpriseURLs], with serverURL +// as the upload base when set and apiURL otherwise. Token text is applied +// only as Authorization and never stored on [Client]. +func NewAuthenticated(token, apiURL, serverURL string) (*Client, error) { + client := github.NewClient(nil).WithAuthToken(token) + if apiURL == "" { + return New(client), nil + } + uploadURL := serverURL + if uploadURL == "" { + uploadURL = apiURL + } + enterprise, err := client.WithEnterpriseURLs(apiURL, uploadURL) + if err != nil { + return nil, fmt.Errorf("github enterprise urls: %w", err) + } + + return New(enterprise), nil +} + +// Get implements [pubgh.ArtifactMeta]. +func (c *Client) Get( + ctx context.Context, + repository pubgh.Repository, + id pubgh.ArtifactID, +) (pubgh.ArtifactMetadata, error) { + if ctx == nil { + return pubgh.ArtifactMetadata{}, errors.New("context is nil") + } + if c == nil || c.github == nil { + return pubgh.ArtifactMetadata{}, errors.New("github client is nil") + } + + return c.get(ctx, repository, id) +} + +// get fetches and maps artifact metadata after exported guards. +func (c *Client) get( + ctx context.Context, + repository pubgh.Repository, + id pubgh.ArtifactID, +) (pubgh.ArtifactMetadata, error) { + artifact, _, err := c.github.Actions.GetArtifact(ctx, repository.Owner, repository.Name, id.Int64()) + if err != nil { + return pubgh.ArtifactMetadata{}, classify(err) + } + + return mapArtifact(artifact) +} + +// classify maps a go-github failure onto a retryable sentinel or a diagnostic. +// +// The returned error never includes request headers, URLs, or token text. +func classify(err error) error { + if errors.Is(err, context.Canceled) { + return fmt.Errorf("%w: request canceled", context.Canceled) + } + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%w: request deadline exceeded", context.DeadlineExceeded) + } + + var rateLimit *github.RateLimitError + if errors.As(err, &rateLimit) { + return fmt.Errorf("%w: rate limited", pubgh.ErrRetryable) + } + var abuse *github.AbuseRateLimitError + if errors.As(err, &abuse) { + return fmt.Errorf("%w: secondary rate limited", pubgh.ErrRetryable) + } + + var apiErr *github.ErrorResponse + if !errors.As(err, &apiErr) || apiErr.Response == nil { + return errors.New("malformed artifact metadata: github request failed") + } + + switch code := apiErr.Response.StatusCode; { + case code == http.StatusNotFound: + return errors.New("artifact not found") + case code == http.StatusUnauthorized || code == http.StatusForbidden: + return fmt.Errorf("github authentication failed: status %d", code) + case code == http.StatusTooManyRequests || code >= http.StatusInternalServerError: + return fmt.Errorf("%w: status %d", pubgh.ErrRetryable, code) + default: + return fmt.Errorf("malformed artifact metadata: status %d", code) + } +} + +// mapArtifact converts a go-github artifact into a domain value. +func mapArtifact(artifact *github.Artifact) (pubgh.ArtifactMetadata, error) { + if artifact == nil { + return pubgh.ArtifactMetadata{}, errors.New("malformed artifact metadata: empty artifact payload") + } + if artifact.ID == nil { + return pubgh.ArtifactMetadata{}, errors.New("malformed artifact metadata: artifact id is missing") + } + id, err := pubgh.ArtifactIDFromInt(*artifact.ID) + if err != nil { + return pubgh.ArtifactMetadata{}, fmt.Errorf("malformed artifact metadata: %w", err) + } + + meta := pubgh.ArtifactMetadata{ID: id} + if artifact.Name != nil { + meta.Name = *artifact.Name + } + if artifact.SizeInBytes != nil { + meta.SizeBytes = *artifact.SizeInBytes + } + if artifact.Expired != nil { + meta.Expired = *artifact.Expired + } + if artifact.ExpiresAt != nil { + meta.ExpiresAt = artifact.ExpiresAt.Time + } + if artifact.Digest != nil && *artifact.Digest != "" { + digest, err := pubgh.ParseArtifactDigest(*artifact.Digest) + if err != nil { + return pubgh.ArtifactMetadata{}, fmt.Errorf("malformed artifact metadata: %w", err) + } + meta.Digest = digest + } + if artifact.WorkflowRun != nil { + if artifact.WorkflowRun.ID == nil { + return pubgh.ArtifactMetadata{}, errors.New("malformed artifact metadata: workflow-run id is missing") + } + run, err := pubgh.RunIDFromInt(*artifact.WorkflowRun.ID) + if err != nil { + return pubgh.ArtifactMetadata{}, fmt.Errorf("malformed artifact metadata: %w", err) + } + meta.HasRun = true + meta.Run = run + } + + return meta, nil +} diff --git a/internal/adapter/ghact/client_test.go b/internal/adapter/ghact/client_test.go new file mode 100644 index 0000000..7fe0426 --- /dev/null +++ b/internal/adapter/ghact/client_test.go @@ -0,0 +1,255 @@ +package ghact_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/google/go-github/v82/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/adapter/ghact" + "github.com/meigma/release/internal/stage/pubgh" +) + +const ( + testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testToken = "ghs_this_must_never_appear_in_errors" +) + +func TestGetMapsArtifact(t *testing.T) { + t.Parallel() + + expires := time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, "/repos/meigma/release/actions/artifacts/11", request.URL.Path) + assert.Equal(t, "Bearer "+testToken, request.Header.Get("Authorization")) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "id": 11, + "name": "release-assets", + "size_in_bytes": 42, + "expired": false, + "expires_at": expires.Format(time.RFC3339), + "digest": testDigest, + "workflow_run": map[string]any{"id": 99}, + })) + })) + t.Cleanup(server.Close) + + got, err := newClient(t, server).Get(context.Background(), mustRepo(t), mustArtifactID(t, 11)) + require.NoError(t, err) + assert.Equal(t, int64(11), got.ID.Int64()) + assert.Equal(t, "release-assets", got.Name) + assert.Equal(t, testDigest, got.Digest.String()) + assert.Equal(t, int64(42), got.SizeBytes) + assert.True(t, got.HasRun) + assert.Equal(t, int64(99), got.Run.Int64()) + assert.True(t, got.ExpiresAt.Equal(expires)) + assert.False(t, got.Expired) +} + +func TestGetClassifiesAPIErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + wantSent error + wantText string + }{ + {name: "absent", status: http.StatusNotFound, wantText: "artifact not found"}, + { + name: "unauthorized", + status: http.StatusUnauthorized, + wantText: "github authentication failed", + }, + {name: "forbidden", status: http.StatusForbidden, wantText: "github authentication failed"}, + { + name: "too many requests", + status: http.StatusTooManyRequests, + wantSent: pubgh.ErrRetryable, + wantText: "retryable", + }, + {name: "server error", status: http.StatusBadGateway, wantSent: pubgh.ErrRetryable, wantText: "retryable"}, + { + name: "other client error", + status: http.StatusUnprocessableEntity, + wantText: "malformed artifact metadata", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(test.status) + _, _ = writer.Write([]byte(`{"message":"nope"}`)) + })) + t.Cleanup(server.Close) + + _, err := newClient(t, server).Get( + context.Background(), + mustRepo(t), + mustArtifactID(t, 1), + ) + require.Error(t, err) + if test.wantSent != nil { + require.ErrorIs(t, err, test.wantSent) + } else { + require.NotErrorIs(t, err, pubgh.ErrRetryable) + } + assert.Contains(t, err.Error(), test.wantText) + assert.NotContains(t, err.Error(), testToken) + }) + } +} + +func TestGetRejectsMalformedPayload(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "name": "release-assets", + })) + })) + t.Cleanup(server.Close) + + _, err := newClient(t, server).Get(context.Background(), mustRepo(t), mustArtifactID(t, 1)) + require.Error(t, err) + assert.Contains(t, err.Error(), "malformed artifact metadata: artifact id is missing") + assert.Contains(t, err.Error(), "artifact id is missing") + assert.NotContains(t, err.Error(), testToken) +} + +func TestGetRejectsUnparseableDigest(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "id": 1, + "digest": "not-a-digest", + "workflow_run": map[string]any{"id": 2}, + })) + })) + t.Cleanup(server.Close) + + _, err := newClient(t, server).Get(context.Background(), mustRepo(t), mustArtifactID(t, 1)) + require.Error(t, err) + assert.Contains(t, err.Error(), "malformed artifact metadata") + assert.NotContains(t, err.Error(), testToken) +} + +func TestGetRejectsNilClient(t *testing.T) { + t.Parallel() + + client := ghact.New(nil) + _, err := client.Get(context.Background(), mustRepo(t), mustArtifactID(t, 1)) + require.Error(t, err) + assert.Contains(t, err.Error(), "github client is nil") +} + +func TestWrappedAPIErrorOmitsToken(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusUnauthorized) + _, _ = writer.Write([]byte(`{"message":"Bad credentials"}`)) + })) + t.Cleanup(server.Close) + + _, err := newClient(t, server).Get(context.Background(), mustRepo(t), mustArtifactID(t, 1)) + require.Error(t, err) + assert.Contains(t, err.Error(), "github authentication failed") + assert.NotContains(t, err.Error(), testToken) + assert.NotContains(t, err.Error(), "Bearer") +} + +func TestGetCanceledErrorOmitsURL(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := newClient(t, server).Get(ctx, mustRepo(t), mustArtifactID(t, 1)) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + assert.Contains(t, err.Error(), "request canceled") + assert.NotContains(t, err.Error(), server.URL) + assert.NotContains(t, err.Error(), testToken) +} + +func TestNewAuthenticatedUsesCustomAPIBase(t *testing.T) { + t.Parallel() + + hit := false + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + hit = true + assert.Equal(t, "/api/v3/repos/meigma/release/actions/artifacts/11", request.URL.Path) + assert.Equal(t, "Bearer "+testToken, request.Header.Get("Authorization")) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "id": 11, + "name": "release-assets", + "digest": testDigest, + "workflow_run": map[string]any{"id": 99}, + })) + })) + t.Cleanup(server.Close) + + client, err := ghact.NewAuthenticated(testToken, server.URL, server.URL) + require.NoError(t, err) + got, err := client.Get(context.Background(), mustRepo(t), mustArtifactID(t, 11)) + require.NoError(t, err) + assert.True(t, hit) + assert.Equal(t, int64(11), got.ID.Int64()) + assert.Equal(t, testDigest, got.Digest.String()) +} + +func TestNewAuthenticatedEmptyAPIURLUsesPublicHost(t *testing.T) { + t.Parallel() + + client, err := ghact.NewAuthenticated(testToken, "", "") + require.NoError(t, err) + require.NotNil(t, client) +} + +// newClient returns an authenticated go-github client pointed at server. +func newClient(t *testing.T, server *httptest.Server) *ghact.Client { + t.Helper() + + parsed, err := url.Parse(server.URL + "/") + require.NoError(t, err) + client := github.NewClient(server.Client()).WithAuthToken(testToken) + client.BaseURL = parsed + + return ghact.New(client) +} + +// mustRepo parses the fixture repository or fails the test. +func mustRepo(t *testing.T) pubgh.Repository { + t.Helper() + + repo, err := pubgh.ParseRepository("meigma/release") + require.NoError(t, err) + + return repo +} + +// mustArtifactID parses an artifact id or fails the test. +func mustArtifactID(t *testing.T, value int64) pubgh.ArtifactID { + t.Helper() + + id, err := pubgh.ArtifactIDFromInt(value) + require.NoError(t, err) + + return id +} diff --git a/internal/adapter/ghact/doc.go b/internal/adapter/ghact/doc.go new file mode 100644 index 0000000..89a0c9d --- /dev/null +++ b/internal/adapter/ghact/doc.go @@ -0,0 +1,8 @@ +// Package ghact implements [pubgh.ArtifactMeta] with go-github. +// +// [New] accepts an already-authenticated [github.Client] so token text never +// enters domain values or error strings. [NewAuthenticated] builds that +// client for the public API or a GITHUB_API_URL enterprise/stub base. Get +// maps GitHub artifact metadata onto [pubgh.ArtifactMetadata] and classifies +// API failures as absent, auth, retryable, or malformed. +package ghact diff --git a/internal/adapter/ghact/mocks/artifact_meta.go b/internal/adapter/ghact/mocks/artifact_meta.go new file mode 100644 index 0000000..3c388d7 --- /dev/null +++ b/internal/adapter/ghact/mocks/artifact_meta.go @@ -0,0 +1,112 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + + "github.com/meigma/release/internal/stage/pubgh" +) + +// NewMockArtifactMeta creates a new instance of MockArtifactMeta. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockArtifactMeta(t interface { + mock.TestingT + Cleanup(func()) +}) *MockArtifactMeta { + mock := &MockArtifactMeta{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockArtifactMeta is an autogenerated mock type for the ArtifactMeta type +type MockArtifactMeta struct { + mock.Mock +} + +type MockArtifactMeta_Expecter struct { + mock *mock.Mock +} + +func (_m *MockArtifactMeta) EXPECT() *MockArtifactMeta_Expecter { + return &MockArtifactMeta_Expecter{mock: &_m.Mock} +} + +// Get provides a mock function for the type MockArtifactMeta +func (_mock *MockArtifactMeta) Get(ctx context.Context, repository pubgh.Repository, id pubgh.ArtifactID) (pubgh.ArtifactMetadata, error) { + ret := _mock.Called(ctx, repository, id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 pubgh.ArtifactMetadata + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubgh.Repository, pubgh.ArtifactID) (pubgh.ArtifactMetadata, error)); ok { + return returnFunc(ctx, repository, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubgh.Repository, pubgh.ArtifactID) pubgh.ArtifactMetadata); ok { + r0 = returnFunc(ctx, repository, id) + } else { + r0 = ret.Get(0).(pubgh.ArtifactMetadata) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubgh.Repository, pubgh.ArtifactID) error); ok { + r1 = returnFunc(ctx, repository, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockArtifactMeta_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type MockArtifactMeta_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - repository pubgh.Repository +// - id pubgh.ArtifactID +func (_e *MockArtifactMeta_Expecter) Get(ctx any, repository any, id any) *MockArtifactMeta_Get_Call { + return &MockArtifactMeta_Get_Call{Call: _e.mock.On("Get", ctx, repository, id)} +} + +func (_c *MockArtifactMeta_Get_Call) Run(run func(ctx context.Context, repository pubgh.Repository, id pubgh.ArtifactID)) *MockArtifactMeta_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 pubgh.Repository + if args[1] != nil { + arg1 = args[1].(pubgh.Repository) + } + var arg2 pubgh.ArtifactID + if args[2] != nil { + arg2 = args[2].(pubgh.ArtifactID) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockArtifactMeta_Get_Call) Return(artifactMetadata pubgh.ArtifactMetadata, err error) *MockArtifactMeta_Get_Call { + _c.Call.Return(artifactMetadata, err) + return _c +} + +func (_c *MockArtifactMeta_Get_Call) RunAndReturn(run func(ctx context.Context, repository pubgh.Repository, id pubgh.ArtifactID) (pubgh.ArtifactMetadata, error)) *MockArtifactMeta_Get_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/ghact/mocks/doc.go b/internal/adapter/ghact/mocks/doc.go new file mode 100644 index 0000000..4fa5c36 --- /dev/null +++ b/internal/adapter/ghact/mocks/doc.go @@ -0,0 +1,5 @@ +// Package mocks contains Mockery-generated doubles for [pubgh.ArtifactMeta]. +// +// Generated files are produced by `mockery` from .mockery.yml. Do not edit +// them by hand. +package mocks diff --git a/internal/cli/doc.go b/internal/cli/doc.go index 0ded9bc..3ea39d1 100644 --- a/internal/cli/doc.go +++ b/internal/cli/doc.go @@ -1,8 +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. +// [LookupEnv] seam. The tree exposes stage, verify handoff, 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/handoff.go b/internal/cli/handoff.go new file mode 100644 index 0000000..ba342b8 --- /dev/null +++ b/internal/cli/handoff.go @@ -0,0 +1,285 @@ +package cli + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/meigma/release/internal/stage/pubgh" +) + +const ( + // commandHandoff is the envelope command path for verify handoff. + commandHandoff = "verify handoff" + // flagArtifactID is the handoff artifact-id flag name. + flagArtifactID = "artifact-id" + // flagDigest is the handoff digest flag name. + flagDigest = "digest" + // envArtifactID is the environment variable for --artifact-id. + envArtifactID = "RELEASE_ARTIFACT_ID" + // envDigest is the environment variable for --digest. + envDigest = "RELEASE_DIGEST" + // envRepository is the Actions repository owner/name pair. + envRepository = "GITHUB_REPOSITORY" + // envRunID is the Actions workflow run identifier. + envRunID = "GITHUB_RUN_ID" + // envGitHubToken is the primary Actions token variable. The value is a + // variable name, not a credential. + envGitHubToken = "GITHUB_TOKEN" //nolint:gosec // Environment variable name. + // envGHToken is the fallback GitHub token variable name. + envGHToken = "GH_TOKEN" + // envAPIURL is the GitHub API base URL. + envAPIURL = "GITHUB_API_URL" + // envServerURL is the GitHub HTML/upload base URL. + envServerURL = "GITHUB_SERVER_URL" + // publicAPIHost is the public github.com API host. + publicAPIHost = "api.github.com" +) + +// newVerifyCommand constructs the verify parent verb. +func newVerifyCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "verify", + Short: "Verify release-unit handoffs and bundles", + Args: requireSubcommand, + RunE: func(_ *cobra.Command, _ []string) error { + return UsageError(errors.New("a verify subcommand is required")) + }, + } + cmd.AddCommand(newHandoffCommand(options)) + + return cmd +} + +// newHandoffCommand constructs the verify handoff verb. +func newHandoffCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "handoff", + Short: "Verify an Actions artifact metadata tuple", + Args: usageNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runHandoff(cmd, options) + }, + } + cmd.Flags().String(flagArtifactID, "", "GitHub Actions artifact id") + cmd.Flags().String(flagDigest, "", "expected GitHub-reported artifact digest") + + return cmd +} + +// runHandoff validates configuration and verifies the artifact metadata tuple. +// +// Missing or malformed configuration is [ErrUsage] and is raised before the +// GitHub port is constructed or called. A tuple mismatch is a verification +// failure. +func runHandoff(cmd *cobra.Command, options Options) error { + expected, err := resolveHandoff(options) + if err != nil { + return writeCommandResult(options, commandHandoff, nil, UsageError(err)) + } + + meta, err := artifactMeta(options, expected) + if err != nil { + return writeCommandResult(options, commandHandoff, nil, err) + } + + observed, err := pubgh.VerifyHandoff(cmd.Context(), meta, expected.Handoff, nil) + if err != nil { + return writeCommandResult(options, commandHandoff, nil, err) + } + if options.settings == nil || !options.settings.JSON { + return nil + } + + return writeCommandResult(options, commandHandoff, handoffResult(observed), nil) +} + +// GitHubEndpoint is the resolved GitHub API location for one invocation. +// +// An empty APIURL selects the public https://api.github.com client. +type GitHubEndpoint struct { + // APIURL is a non-public GitHub API base. Empty selects api.github.com. + APIURL string + // ServerURL is the GitHub HTML/upload base used with a non-public APIURL. + ServerURL string +} + +// handoffConfig is the resolved verify-handoff configuration. +type handoffConfig struct { + // Handoff is the expected metadata tuple. + Handoff pubgh.Handoff + // Token is the already-resolved GitHub credential. It is never logged. + Token string + // Endpoint is the GitHub API location. Empty APIURL is the public API. + Endpoint GitHubEndpoint +} + +// resolveHandoff parses flags and Actions environment into a handoff tuple. +// +// It performs no network I/O. +func resolveHandoff(options Options) (handoffConfig, error) { + settings := Settings{} + if options.settings != nil { + settings = *options.settings + } + if settings.ArtifactID == "" { + return handoffConfig{}, fmt.Errorf("--%s is required", flagArtifactID) + } + if settings.Digest == "" { + return handoffConfig{}, fmt.Errorf("--%s is required", flagDigest) + } + + artifactID, err := pubgh.ParseArtifactID(settings.ArtifactID) + if err != nil { + return handoffConfig{}, err + } + digest, err := pubgh.ParseArtifactDigest(settings.Digest) + if err != nil { + return handoffConfig{}, err + } + + repositoryRaw, ok := options.LookupEnv(envRepository) + if !ok || repositoryRaw == "" { + return handoffConfig{}, fmt.Errorf("%s is required", envRepository) + } + repository, err := pubgh.ParseRepository(repositoryRaw) + if err != nil { + return handoffConfig{}, err + } + + runRaw, ok := options.LookupEnv(envRunID) + if !ok || runRaw == "" { + return handoffConfig{}, fmt.Errorf("%s is required", envRunID) + } + runID, err := pubgh.ParseRunID(runRaw) + if err != nil { + return handoffConfig{}, err + } + + handoff, err := pubgh.NewHandoff(repository, runID, artifactID, digest) + if err != nil { + return handoffConfig{}, err + } + endpoint, err := resolveGitHubEndpoint(options.LookupEnv) + if err != nil { + return handoffConfig{}, err + } + + return handoffConfig{ + Handoff: handoff, + Token: resolveToken(options.LookupEnv), + Endpoint: endpoint, + }, nil +} + +// artifactMeta returns the injected port or constructs one from the token. +func artifactMeta(options Options, expected handoffConfig) (pubgh.ArtifactMeta, error) { + if options.ArtifactMeta != nil { + return options.ArtifactMeta, nil + } + if expected.Token == "" { + return nil, UsageError(fmt.Errorf("%s or %s is required", envGitHubToken, envGHToken)) + } + if options.NewArtifactMeta == nil { + return nil, errors.New("artifact metadata factory is not configured") + } + + meta, err := options.NewArtifactMeta(expected.Token, expected.Endpoint) + if err != nil { + return nil, UsageError(fmt.Errorf("github client: %w", err)) + } + if meta == nil { + return nil, errors.New("artifact metadata factory returned nil") + } + + return meta, nil +} + +// resolveGitHubEndpoint reads GITHUB_API_URL and GITHUB_SERVER_URL. +// +// An absent or public https://api.github.com value keeps the default client. +// Any other value must be an absolute URL and is passed to the factory as-is. +func resolveGitHubEndpoint(lookup LookupEnv) (GitHubEndpoint, error) { + apiURL, err := optionalAbsoluteURL(lookup, envAPIURL) + if err != nil { + return GitHubEndpoint{}, err + } + if apiURL == "" || isPublicAPI(apiURL) { + return GitHubEndpoint{}, nil + } + serverURL, err := optionalAbsoluteURL(lookup, envServerURL) + if err != nil { + return GitHubEndpoint{}, err + } + + return GitHubEndpoint{APIURL: apiURL, ServerURL: serverURL}, nil +} + +// optionalAbsoluteURL returns a named environment URL, or empty when unset. +func optionalAbsoluteURL(lookup LookupEnv, name string) (string, error) { + if lookup == nil { + return "", nil + } + raw, ok := lookup(name) + if !ok { + return "", nil + } + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("%s is empty", name) + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("%s %q is not an absolute URL", name, trimmed) + } + + return trimmed, nil +} + +// isPublicAPI reports whether raw is the public github.com API. +func isPublicAPI(raw string) bool { + parsed, err := url.Parse(raw) + if err != nil { + return false + } + + return strings.EqualFold(parsed.Hostname(), publicAPIHost) +} + +// resolveToken returns GITHUB_TOKEN, or GH_TOKEN when GITHUB_TOKEN is unset. +func resolveToken(lookup LookupEnv) string { + if lookup == nil { + return "" + } + if value, ok := lookup(envGitHubToken); ok && value != "" { + return value + } + if value, ok := lookup(envGHToken); ok && value != "" { + return value + } + + return "" +} + +// handoffResult builds the success envelope payload. +func handoffResult(observed pubgh.ArtifactMetadata) HandoffResult { + expiresAt := "" + if !observed.ExpiresAt.IsZero() { + expiresAt = observed.ExpiresAt.UTC().Format(time.RFC3339) + } + + return HandoffResult{ + Artifact: ArtifactHandoffResult{ + ID: observed.ID.Int64(), + Name: observed.Name, + Digest: observed.Digest.String(), + SizeBytes: observed.SizeBytes, + RunID: observed.Run.Int64(), + ExpiresAt: expiresAt, + }, + } +} diff --git a/internal/cli/handoff_test.go b/internal/cli/handoff_test.go new file mode 100644 index 0000000..14e1316 --- /dev/null +++ b/internal/cli/handoff_test.go @@ -0,0 +1,458 @@ +package cli_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/adapter/ghact" + ghactmocks "github.com/meigma/release/internal/adapter/ghact/mocks" + "github.com/meigma/release/internal/cli" + "github.com/meigma/release/internal/stage/pubgh" +) + +const ( + handoffDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + handoffToken = "ghs_should_never_appear" +) + +func TestVerifyHandoffMissingRepositoryIsUsage(t *testing.T) { + t.Parallel() + + stdout, _, err := executeHandoff(t, map[string]string{ + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + }, []string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}, unusedMeta(t)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), "GITHUB_REPOSITORY is required") +} + +func TestVerifyHandoffMissingRunIsUsage(t *testing.T) { + t.Parallel() + + _, _, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_TOKEN": handoffToken, + }, []string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}, unusedMeta(t)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "GITHUB_RUN_ID is required") +} + +func TestVerifyHandoffMalformedRepositoryIsUsage(t *testing.T) { + t.Parallel() + + _, _, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "not-a-repo", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + }, []string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}, unusedMeta(t)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "not an owner/name pair") +} + +func TestVerifyHandoffMissingFlagsIsUsage(t *testing.T) { + t.Parallel() + + _, _, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + }, []string{"verify", "handoff"}, unusedMeta(t)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "--artifact-id is required") +} + +func TestVerifyHandoffConfigErrorBeforeNetwork(t *testing.T) { + t.Parallel() + + _, _, err := executeHandoff(t, nil, []string{ + "verify", "handoff", + "--artifact-id", "1", + "--digest", handoffDigest, + }, unusedMeta(t)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) +} + +func TestVerifyHandoffMissingTokenIsUsage(t *testing.T) { + t.Parallel() + + called := false + stdout := &strings.Builder{} + stderr := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: stderr, + LookupEnv: func(key string) (string, bool) { + values := map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + } + value, ok := values[key] + return value, ok + }, + NewArtifactMeta: func(string, cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { + called = true + return unusedMeta(t), nil + }, + }) + command.SetArgs([]string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}) + err := command.Execute() + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "GITHUB_TOKEN or GH_TOKEN is required") + assert.False(t, called) +} + +func TestVerifyHandoffJSONSuccess(t *testing.T) { + t.Parallel() + + expires := time.Date(2026, 8, 19, 15, 4, 5, 0, time.UTC) + meta := matchingMeta(t, 11, 100, expires) + + stdout, stderr, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + }, []string{"verify", "handoff", "--artifact-id", "11", "--digest", handoffDigest, "--json"}, meta) + 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.Equal(t, "verify handoff", envelope.Command) + assert.True(t, envelope.OK) + + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.HandoffResult + require.NoError(t, json.Unmarshal(raw, &result)) + assert.Equal(t, int64(11), result.Artifact.ID) + assert.Equal(t, "release-assets", result.Artifact.Name) + assert.Equal(t, handoffDigest, result.Artifact.Digest) + assert.Equal(t, int64(42), result.Artifact.SizeBytes) + assert.Equal(t, int64(100), result.Artifact.RunID) + assert.Equal(t, "2026-08-19T15:04:05Z", result.Artifact.ExpiresAt) +} + +func TestVerifyHandoffSilentSuccessWithoutJSON(t *testing.T) { + t.Parallel() + + meta := matchingMeta(t, 11, 100, time.Time{}) + stdout, stderr, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GH_TOKEN": handoffToken, + }, []string{"verify", "handoff", "--artifact-id", "11", "--digest", handoffDigest}, meta) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestVerifyHandoffMismatchIsExitOne(t *testing.T) { + t.Parallel() + + meta := matchingMeta(t, 11, 200, time.Time{}) + stdout, _, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + }, []string{"verify", "handoff", "--artifact-id", "11", "--digest", handoffDigest, "--json"}, meta) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "belongs to workflow run 200") + assert.NotContains(t, err.Error(), handoffToken) + assert.Contains(t, stdout, `"command":"verify handoff"`) + assert.Contains(t, stdout, `"ok":false`) +} + +func TestVerifyHandoffFlagOverridesEnv(t *testing.T) { + t.Parallel() + + var gotID pubgh.ArtifactID + meta := ghactmocks.NewMockArtifactMeta(t) + meta.EXPECT(). + Get(mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, _ pubgh.Repository, id pubgh.ArtifactID) { + gotID = id + }). + Return(pubgh.ArtifactMetadata{ + ID: mustTestArtifactID(t, 11), + Digest: mustTestDigest(t, handoffDigest), + HasRun: true, + Run: mustTestRunID(t, 100), + }, nil). + Once() + + _, _, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + "RELEASE_ARTIFACT_ID": "999", + "RELEASE_DIGEST": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, []string{"verify", "handoff", "--artifact-id", "11", "--digest", handoffDigest}, meta) + require.NoError(t, err) + assert.Equal(t, int64(11), gotID.Int64()) +} + +func TestVerifyHandoffAbsentAPIURLKeepsDefault(t *testing.T) { + t.Parallel() + + var got cli.GitHubEndpoint + called := false + stdout := &strings.Builder{} + stderr := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: stderr, + LookupEnv: func(key string) (string, bool) { + values := map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + } + value, ok := values[key] + return value, ok + }, + NewArtifactMeta: func(_ string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { + called = true + got = endpoint + return matchingMeta(t, 1, 100, time.Time{}), nil + }, + }) + command.SetArgs([]string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}) + require.NoError(t, command.Execute()) + require.True(t, called) + assert.Empty(t, got.APIURL) + assert.Empty(t, got.ServerURL) +} + +func TestVerifyHandoffPublicAPIURLKeepsDefault(t *testing.T) { + t.Parallel() + + var got cli.GitHubEndpoint + command := cli.NewRootCommand(cli.Options{ + Out: &strings.Builder{}, + Err: &strings.Builder{}, + LookupEnv: func(key string) (string, bool) { + values := map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + "GITHUB_API_URL": "https://api.github.com", + } + value, ok := values[key] + return value, ok + }, + NewArtifactMeta: func(_ string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { + got = endpoint + return matchingMeta(t, 1, 100, time.Time{}), nil + }, + }) + command.SetArgs([]string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}) + require.NoError(t, command.Execute()) + assert.Empty(t, got.APIURL) +} + +func TestVerifyHandoffCustomAPIURLIsUsedVerbatim(t *testing.T) { + t.Parallel() + + var got cli.GitHubEndpoint + command := cli.NewRootCommand(cli.Options{ + Out: &strings.Builder{}, + Err: &strings.Builder{}, + LookupEnv: func(key string) (string, bool) { + values := map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + "GITHUB_API_URL": "https://github.example.internal/api/v3", + "GITHUB_SERVER_URL": "https://github.example.internal", + } + value, ok := values[key] + return value, ok + }, + NewArtifactMeta: func(_ string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { + got = endpoint + return matchingMeta(t, 1, 100, time.Time{}), nil + }, + }) + command.SetArgs([]string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}) + require.NoError(t, command.Execute()) + assert.Equal(t, "https://github.example.internal/api/v3", got.APIURL) + assert.Equal(t, "https://github.example.internal", got.ServerURL) +} + +func TestVerifyHandoffMalformedAPIURLIsUsage(t *testing.T) { + t.Parallel() + + _, _, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + "GITHUB_API_URL": "not-a-url", + }, []string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}, unusedMeta(t)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "GITHUB_API_URL") + assert.Contains(t, err.Error(), "not an absolute URL") +} + +func TestVerifyHandoffEmptyAPIURLIsUsage(t *testing.T) { + t.Parallel() + + _, _, err := executeHandoff(t, map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + "GITHUB_API_URL": " ", + }, []string{"verify", "handoff", "--artifact-id", "1", "--digest", handoffDigest}, unusedMeta(t)) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "GITHUB_API_URL is empty") +} + +func TestVerifyHandoffCustomAPIURLHitsStub(t *testing.T) { + t.Parallel() + + hit := false + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + hit = true + assert.Equal(t, "/api/v3/repos/meigma/release/actions/artifacts/11", request.URL.Path) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "id": 11, + "name": "release-assets", + "digest": handoffDigest, + "expired": false, + "workflow_run": map[string]any{"id": 100}, + })) + })) + t.Cleanup(server.Close) + + stdout := &strings.Builder{} + stderr := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: stderr, + LookupEnv: func(key string) (string, bool) { + values := map[string]string{ + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_RUN_ID": "100", + "GITHUB_TOKEN": handoffToken, + "GITHUB_API_URL": server.URL, + } + value, ok := values[key] + return value, ok + }, + NewArtifactMeta: func(token string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { + assert.Equal(t, server.URL, endpoint.APIURL) + return ghact.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) + }, + }) + command.SetArgs([]string{"verify", "handoff", "--artifact-id", "11", "--digest", handoffDigest, "--json"}) + require.NoError(t, command.Execute()) + assert.True(t, hit, "request must arrive at GITHUB_API_URL, not api.github.com") + assert.Contains(t, stdout.String(), `"command":"verify handoff"`) + assert.Contains(t, stdout.String(), `"ok":true`) +} + +// unusedMeta returns a generated mock that fails if Get is called. +func unusedMeta(t *testing.T) *ghactmocks.MockArtifactMeta { + t.Helper() + + return ghactmocks.NewMockArtifactMeta(t) +} + +// matchingMeta returns a generated mock that serves one successful lookup. +func matchingMeta(t *testing.T, artifactID, runID int64, expires time.Time) *ghactmocks.MockArtifactMeta { + t.Helper() + + meta := ghactmocks.NewMockArtifactMeta(t) + meta.EXPECT(). + Get(mock.Anything, mock.Anything, mock.Anything). + Return(pubgh.ArtifactMetadata{ + ID: mustTestArtifactID(t, artifactID), + Name: "release-assets", + Digest: mustTestDigest(t, handoffDigest), + SizeBytes: 42, + HasRun: true, + Run: mustTestRunID(t, runID), + ExpiresAt: expires, + }, nil). + Once() + + return meta +} + +// executeHandoff runs verify handoff with an injected metadata port. +func executeHandoff( + t *testing.T, + env map[string]string, + args []string, + meta pubgh.ArtifactMeta, +) (string, string, error) { + t.Helper() + + if env == nil { + env = map[string]string{} + } + + stdout := &strings.Builder{} + stderr := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: stderr, + LookupEnv: func(key string) (string, bool) { + value, ok := env[key] + return value, ok + }, + ArtifactMeta: meta, + }) + command.SetArgs(args) + err := command.Execute() + + return stdout.String(), stderr.String(), err +} + +// mustTestArtifactID constructs an ArtifactID or fails the test. +func mustTestArtifactID(t *testing.T, value int64) pubgh.ArtifactID { + t.Helper() + + id, err := pubgh.ArtifactIDFromInt(value) + require.NoError(t, err) + + return id +} + +// mustTestRunID constructs a RunID or fails the test. +func mustTestRunID(t *testing.T, value int64) pubgh.RunID { + t.Helper() + + id, err := pubgh.RunIDFromInt(value) + require.NoError(t, err) + + return id +} + +// mustTestDigest constructs an ArtifactDigest or fails the test. +func mustTestDigest(t *testing.T, value string) pubgh.ArtifactDigest { + t.Helper() + + digest, err := pubgh.ParseArtifactDigest(value) + require.NoError(t, err) + + return digest +} diff --git a/internal/cli/result.go b/internal/cli/result.go index d444e2f..6923ec4 100644 --- a/internal/cli/result.go +++ b/internal/cli/result.go @@ -31,9 +31,10 @@ 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. +// Schema is always [Schema]. Command is the verb path ("stage", "version", +// or "verify handoff"). 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"` @@ -71,6 +72,28 @@ type StageResult struct { Binaries map[string]BinaryResult `json:"binaries"` } +// ArtifactHandoffResult is one verified Actions artifact. +type ArtifactHandoffResult struct { + // ID is the GitHub artifact identifier. + ID int64 `json:"id"` + // Name is the GitHub artifact name. + Name string `json:"name"` + // Digest is the GitHub-reported digest, always sha256-prefixed. + Digest string `json:"digest"` + // SizeBytes is the reported archive size. + SizeBytes int64 `json:"size_bytes"` + // RunID is the workflow run that owns the artifact. + RunID int64 `json:"run_id"` + // ExpiresAt is the RFC3339 expiry instant, or empty when GitHub omitted it. + ExpiresAt string `json:"expires_at"` +} + +// HandoffResult is the --json payload for verify handoff. +type HandoffResult struct { + // Artifact is the verified Actions artifact metadata. + Artifact ArtifactHandoffResult `json:"artifact"` +} + // ErrorResult is the --json payload for a failed command. type ErrorResult struct { // Error is the diagnostic string also written to stderr. diff --git a/internal/cli/root.go b/internal/cli/root.go index 06252e3..b4b2e6b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,6 +9,8 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/meigma/release/internal/stage/pubgh" ) const ( @@ -37,6 +39,10 @@ type Settings struct { Profile string // Dist is the selected --dist / RELEASE_DIST path. Dist string + // ArtifactID is the selected --artifact-id / RELEASE_ARTIFACT_ID value. + ArtifactID string + // Digest is the selected --digest / RELEASE_DIGEST value. + Digest string // JSON reports whether --json / RELEASE_JSON requested structured output. JSON bool } @@ -59,10 +65,14 @@ type Options struct { Out io.Writer // Err receives diagnostics and human-readable status. Err io.Writer - // LookupEnv resolves RELEASE_* variables. Nil selects [os.LookupEnv]. + // LookupEnv resolves RELEASE_* and Actions environment variables. Nil selects [os.LookupEnv]. LookupEnv LookupEnv // Build controls version output. Build BuildInfo + // ArtifactMeta, when set, is the handoff metadata port. Tests inject it. + ArtifactMeta pubgh.ArtifactMeta + // NewArtifactMeta constructs the metadata port from a token and API endpoint. + NewArtifactMeta func(token string, endpoint GitHubEndpoint) (pubgh.ArtifactMeta, error) // settings is filled after flags are parsed. settings *Settings } @@ -98,6 +108,7 @@ func NewRootCommand(options Options) *cobra.Command { root.SetFlagErrorFunc(flagParseError) root.PersistentFlags().Bool("json", false, "emit one JSON result document on stdout") root.AddCommand(newStageCommand(options)) + root.AddCommand(newVerifyCommand(options)) root.AddCommand(newVersionCommand(options)) return root @@ -143,9 +154,11 @@ func (options Options) withDefaults() Options { // 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), + Profile: resolveString(cmd, flagProfile, envProfile, lookup), + Dist: resolveString(cmd, flagDist, envDist, lookup), + ArtifactID: resolveString(cmd, flagArtifactID, envArtifactID, lookup), + Digest: resolveString(cmd, flagDigest, envDigest, lookup), + JSON: resolveBool(cmd, "json", envJSON, lookup), } } diff --git a/internal/stage/pubgh/doc.go b/internal/stage/pubgh/doc.go new file mode 100644 index 0000000..41a6f1b --- /dev/null +++ b/internal/stage/pubgh/doc.go @@ -0,0 +1,7 @@ +// Package pubgh verifies GitHub Actions artifact handoff metadata. +// +// [VerifyHandoff] is a pure check over the [ArtifactMeta] port. It confirms +// that an artifact exists, belongs to the expected workflow run, has not +// expired, and reports the caller-supplied digest. It does not download the +// artifact and does not recompute the Actions ZIP transport digest. +package pubgh diff --git a/internal/stage/pubgh/handoff.go b/internal/stage/pubgh/handoff.go new file mode 100644 index 0000000..0367fc2 --- /dev/null +++ b/internal/stage/pubgh/handoff.go @@ -0,0 +1,432 @@ +package pubgh + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode" +) + +const ( + // digestPrefix is the canonical digest algorithm prefix. + digestPrefix = "sha256:" + // maxSafeID is JavaScript Number.MAX_SAFE_INTEGER. + // + // The replaced github-script blocks rejected identifiers outside + // Number.isSafeInteger. Artifact and run IDs must stay inside that range. + maxSafeID int64 = 1<<53 - 1 + // retryAttempts is one initial Get plus three octokit retries. + retryAttempts = 4 + // retryWait is the first backoff; later waits double (1s, 2s, 4s). + retryWait = time.Second +) + +// Sentinel errors classified for the handoff check. +var ( + // ErrRetryable reports a transient GitHub API failure that VerifyHandoff retries. + ErrRetryable = errors.New("retryable github error") + // ErrHandoffMismatch reports that observed artifact metadata failed the tuple check. + ErrHandoffMismatch = errors.New("handoff mismatch") +) + +// ArtifactID is a positive GitHub Actions artifact identifier. +// +// The only constructors are [ParseArtifactID] and [ArtifactIDFromInt]. +// The zero value is invalid. +type ArtifactID int64 + +// RunID is a positive GitHub Actions workflow run identifier. +// +// The only constructors are [ParseRunID] and [RunIDFromInt]. The zero +// value is invalid. +type RunID int64 + +// ArtifactDigest is a lowercase sha256:<64 hex> digest. +// +// The only constructor is [ParseArtifactDigest], which accepts an optional +// sha256: prefix and normalizes hex to lowercase. The zero value is invalid. +type ArtifactDigest string + +// Repository is a GitHub owner/name pair. +// +// The only constructor is [ParseRepository]. The zero value is invalid. +type Repository struct { + // Owner is the account or organization that owns the repository. + Owner string + // Name is the repository name. + Name string +} + +// Handoff is the expected Actions artifact metadata tuple. +// +// The only constructor is [NewHandoff]. The zero value is invalid. +type Handoff struct { + // Repository is the repository that owns the artifact. + Repository Repository + // Run is the workflow run that must own the artifact. + Run RunID + // Artifact is the expected artifact identifier. + Artifact ArtifactID + // Digest is the expected GitHub-reported artifact digest. + Digest ArtifactDigest +} + +// ArtifactMetadata is the observed Actions artifact tuple. +// +// Values are produced by [ArtifactMeta.Get]. A zero ArtifactMetadata is +// invalid and must not be treated as a successful lookup. +type ArtifactMetadata struct { + // ID is the observed artifact identifier. + ID ArtifactID + // Name is the observed artifact name. + Name string + // Digest is the GitHub-reported digest. The zero value means GitHub + // reported no digest. + Digest ArtifactDigest + // SizeBytes is the reported archive size. + SizeBytes int64 + // HasRun reports whether workflow-run metadata was present. + HasRun bool + // Run is the workflow run that produced the artifact. It is only + // valid when HasRun is true. + Run RunID + // ExpiresAt is the reported expiry instant. The zero value means + // GitHub reported no expiry. + ExpiresAt time.Time + // Expired reports GitHub's expired flag. + Expired bool +} + +// ArtifactMeta fetches Actions artifact metadata. +type ArtifactMeta interface { + // Get returns metadata for id in repository. + // + // Callers must not assume Get downloads the archive or recomputes + // its digest. + Get(ctx context.Context, repository Repository, id ArtifactID) (ArtifactMetadata, error) +} + +// SleepFunc waits for d or until ctx is cancelled. +type SleepFunc func(ctx context.Context, d time.Duration) error + +// ParseArtifactID constructs an [ArtifactID] from a decimal string. +func ParseArtifactID(raw string) (ArtifactID, error) { + value, err := parseSafeID("artifact id", raw) + if err != nil { + return 0, err + } + + return ArtifactID(value), nil +} + +// ArtifactIDFromInt constructs an [ArtifactID] from a positive safe integer. +func ArtifactIDFromInt(value int64) (ArtifactID, error) { + if err := checkSafeID("artifact id", value); err != nil { + return 0, err + } + + return ArtifactID(value), nil +} + +// Int64 returns the identifier as int64. +func (id ArtifactID) Int64() int64 { + return int64(id) +} + +// String returns the decimal identifier. +func (id ArtifactID) String() string { + return strconv.FormatInt(int64(id), 10) +} + +// ParseRunID constructs a [RunID] from a decimal string. +func ParseRunID(raw string) (RunID, error) { + value, err := parseSafeID("run id", raw) + if err != nil { + return 0, err + } + + return RunID(value), nil +} + +// RunIDFromInt constructs a [RunID] from a positive safe integer. +func RunIDFromInt(value int64) (RunID, error) { + if err := checkSafeID("run id", value); err != nil { + return 0, err + } + + return RunID(value), nil +} + +// Int64 returns the identifier as int64. +func (id RunID) Int64() int64 { + return int64(id) +} + +// String returns the decimal identifier. +func (id RunID) String() string { + return strconv.FormatInt(int64(id), 10) +} + +// ParseArtifactDigest constructs an [ArtifactDigest] from a SHA-256 hex string. +// +// An optional sha256: prefix is accepted. Uppercase hex is normalized to +// lowercase. The returned value always uses the sha256: prefix. +func ParseArtifactDigest(raw string) (ArtifactDigest, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("artifact digest is empty") + } + + hexPart, found := strings.CutPrefix(strings.ToLower(trimmed), digestPrefix) + if !found { + hexPart = strings.ToLower(trimmed) + } + if len(hexPart) != hex.EncodedLen(sha256.Size) { + return "", fmt.Errorf( + "artifact digest %q has length %d, want %d", + hexPart, + len(hexPart), + hex.EncodedLen(sha256.Size), + ) + } + for _, r := range hexPart { + if !isHex(r) { + return "", fmt.Errorf("artifact digest %q is not hexadecimal", hexPart) + } + } + + return ArtifactDigest(digestPrefix + hexPart), nil +} + +// String returns the canonical sha256: digest. +func (d ArtifactDigest) String() string { + return string(d) +} + +// ParseRepository constructs a [Repository] from an owner/name pair. +func ParseRepository(raw string) (Repository, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return Repository{}, errors.New("repository is empty") + } + + owner, name, ok := strings.Cut(trimmed, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return Repository{}, fmt.Errorf("repository %q is not an owner/name pair", trimmed) + } + + return Repository{Owner: owner, Name: name}, nil +} + +// String returns the owner/name pair. +func (r Repository) String() string { + return r.Owner + "/" + r.Name +} + +// NewHandoff constructs a [Handoff] from already-validated domain values. +func NewHandoff(repository Repository, run RunID, artifact ArtifactID, digest ArtifactDigest) (Handoff, error) { + handoff := Handoff{ + Repository: repository, + Run: run, + Artifact: artifact, + Digest: digest, + } + if err := handoff.validate(); err != nil { + return Handoff{}, err + } + + return handoff, nil +} + +// VerifyHandoff confirms that the artifact metadata matches expected. +// +// The artifact must exist, belong to expected.Run, not be expired, include +// workflow-run metadata, and report expected.Digest after normalization. +// A cancelled [context.Context] fails before the port is called. A nil +// context or port is rejected. Failures name what mismatched and never +// include credentials. +// +// Get is called at most four times. Failures wrapping [ErrRetryable] wait +// 1s, then 2s, then 4s between attempts. Context cancellation returns +// immediately. Absent, authentication, and malformed responses are never +// retried. A nil sleep uses a context-aware timer. +func VerifyHandoff( + ctx context.Context, + meta ArtifactMeta, + expected Handoff, + sleep SleepFunc, +) (ArtifactMetadata, error) { + if ctx == nil { + return ArtifactMetadata{}, errors.New("context is nil") + } + if meta == nil { + return ArtifactMetadata{}, errors.New("artifact metadata port is nil") + } + if err := expected.validate(); err != nil { + return ArtifactMetadata{}, err + } + if err := ctx.Err(); err != nil { + return ArtifactMetadata{}, fmt.Errorf("verify handoff: %w", err) + } + if sleep == nil { + sleep = sleepContext + } + + return verifyHandoff(ctx, meta, expected, sleep) +} + +// verifyHandoff checks the observed tuple after exported guards. +func verifyHandoff( + ctx context.Context, + meta ArtifactMeta, + expected Handoff, + sleep SleepFunc, +) (ArtifactMetadata, error) { + var lastErr error + for attempt := 1; attempt <= retryAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return ArtifactMetadata{}, fmt.Errorf("verify handoff: %w", err) + } + observed, err := meta.Get(ctx, expected.Repository, expected.Artifact) + if err != nil { + lastErr = err + if !errors.Is(err, ErrRetryable) || attempt == retryAttempts { + if errors.Is(err, ErrRetryable) { + return ArtifactMetadata{}, fmt.Errorf( + "get artifact %s after %d attempts: %w", + expected.Artifact, + attempt, + err, + ) + } + + return ArtifactMetadata{}, fmt.Errorf("get artifact %s: %w", expected.Artifact, err) + } + if err := sleep(ctx, retryWait<<(attempt-1)); err != nil { + return ArtifactMetadata{}, fmt.Errorf("verify handoff: %w", err) + } + + continue + } + + return matchHandoff(expected, observed) + } + if lastErr == nil { + return ArtifactMetadata{}, errors.New("get artifact: retry budget exhausted") + } + + return ArtifactMetadata{}, fmt.Errorf( + "get artifact %s after %d attempts: %w", + expected.Artifact, + retryAttempts, + lastErr, + ) +} + +// matchHandoff compares observed metadata against the expected tuple. +func matchHandoff(expected Handoff, observed ArtifactMetadata) (ArtifactMetadata, error) { + if !observed.HasRun { + return ArtifactMetadata{}, fmt.Errorf( + "%w: artifact has no workflow-run metadata: artifact %s", + ErrHandoffMismatch, + expected.Artifact, + ) + } + if observed.Expired { + return ArtifactMetadata{}, fmt.Errorf("%w: artifact %s has expired", ErrHandoffMismatch, expected.Artifact) + } + if observed.Run != expected.Run { + return ArtifactMetadata{}, fmt.Errorf( + "%w: artifact %s belongs to workflow run %s, expected %s", + ErrHandoffMismatch, + expected.Artifact, + observed.Run, + expected.Run, + ) + } + if observed.Digest != expected.Digest { + got := observed.Digest.String() + if got == "" { + got = "" + } + + return ArtifactMetadata{}, fmt.Errorf( + "%w: expected %s, got %s", + ErrHandoffMismatch, + expected.Digest, + got, + ) + } + + return observed, nil +} + +// sleepContext waits for d or returns ctx.Err() if the context ends first. +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// validate rejects a zero or incomplete handoff tuple. +func (h Handoff) validate() error { + if h.Repository.Owner == "" || h.Repository.Name == "" { + return errors.New("handoff repository is empty") + } + if h.Run == 0 { + return errors.New("handoff run id is empty") + } + if h.Artifact == 0 { + return errors.New("handoff artifact id is empty") + } + if h.Digest == "" { + return errors.New("handoff digest is empty") + } + + return nil +} + +// parseSafeID parses a decimal identifier and checks the safe-integer bound. +func parseSafeID(label, raw string) (int64, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return 0, fmt.Errorf("%s is empty", label) + } + + value, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s %q", label, trimmed) + } + if err := checkSafeID(label, value); err != nil { + return 0, err + } + + return value, nil +} + +// checkSafeID rejects non-positive identifiers and values above maxSafeID. +func checkSafeID(label string, value int64) error { + if value <= 0 { + return fmt.Errorf("%s %d is not positive", label, value) + } + if value > maxSafeID { + return fmt.Errorf("%s %d exceeds the safe integer range", label, value) + } + + 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/pubgh/handoff_test.go b/internal/stage/pubgh/handoff_test.go new file mode 100644 index 0000000..0aa6158 --- /dev/null +++ b/internal/stage/pubgh/handoff_test.go @@ -0,0 +1,495 @@ +package pubgh_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + ghactmocks "github.com/meigma/release/internal/adapter/ghact/mocks" + "github.com/meigma/release/internal/stage/pubgh" +) + +const ( + validDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + otherDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + unprefixedHex = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +) + +func TestParseArtifactID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want int64 + wantErr string + }{ + {name: "positive identifier", input: "42", want: 42}, + {name: "trims surrounding space", input: " 7 ", want: 7}, + {name: "empty", input: "", wantErr: "artifact id is empty"}, + {name: "zero", input: "0", wantErr: "not positive"}, + {name: "negative", input: "-1", wantErr: "not positive"}, + {name: "not an integer", input: "1.5", wantErr: "invalid artifact id"}, + {name: "above safe integer range", input: "9007199254740992", wantErr: "exceeds the safe integer range"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := pubgh.ParseArtifactID(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.Int64()) + }) + } +} + +func TestParseRunID(t *testing.T) { + t.Parallel() + + got, err := pubgh.ParseRunID("99") + require.NoError(t, err) + assert.Equal(t, int64(99), got.Int64()) + + _, err = pubgh.ParseRunID("0") + require.Error(t, err) + assert.Contains(t, err.Error(), "not positive") +} + +func TestParseArtifactDigest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr string + }{ + { + name: "prefixed lowercase", + input: validDigest, + want: validDigest, + }, + { + name: "unprefixed uppercase is normalized", + input: unprefixedHex, + want: validDigest, + }, + { + name: "prefixed uppercase is normalized", + input: "SHA256:" + unprefixedHex, + want: validDigest, + }, + { + name: "empty digest", + input: "", + wantErr: "artifact digest is empty", + }, + { + name: "whitespace only", + input: " ", + wantErr: "artifact digest is empty", + }, + { + name: "wrong length", + input: "sha256:aaaa", + wantErr: "has length 4, want 64", + }, + { + name: "non hex", + input: "sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + wantErr: "is not hexadecimal", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := pubgh.ParseArtifactDigest(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.String()) + }) + } +} + +func TestParseRepository(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr string + }{ + {name: "owner name pair", input: "meigma/release", want: "meigma/release"}, + {name: "empty", input: "", wantErr: "repository is empty"}, + {name: "missing name", input: "meigma/", wantErr: "not an owner/name pair"}, + {name: "missing owner", input: "/release", wantErr: "not an owner/name pair"}, + {name: "extra slash", input: "meigma/release/extra", wantErr: "not an owner/name pair"}, + {name: "no slash", input: "release", wantErr: "not an owner/name pair"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := pubgh.ParseRepository(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.String()) + }) + } +} + +func TestVerifyHandoff(t *testing.T) { + t.Parallel() + + expected := mustHandoff(t, 100) + now := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + ctx func() context.Context + setup func(meta *ghactmocks.MockArtifactMeta) + expected pubgh.Handoff + wantErr string + wantSent error + }{ + { + name: "matching same-run artifact", + setup: func(meta *ghactmocks.MockArtifactMeta) { + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{ + ID: expected.Artifact, + Name: "release-assets", + Digest: expected.Digest, + SizeBytes: 12, + HasRun: true, + Run: expected.Run, + ExpiresAt: now, + }, nil). + Once() + }, + expected: expected, + }, + { + name: "expired artifact", + setup: func(meta *ghactmocks.MockArtifactMeta) { + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{ + ID: expected.Artifact, + Digest: expected.Digest, + HasRun: true, + Run: expected.Run, + Expired: true, + }, nil). + Once() + }, + expected: expected, + wantErr: "has expired", + wantSent: pubgh.ErrHandoffMismatch, + }, + { + name: "wrong run", + setup: func(meta *ghactmocks.MockArtifactMeta) { + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{ + ID: expected.Artifact, + Digest: expected.Digest, + HasRun: true, + Run: mustRunID(t, 200), + }, nil). + Once() + }, + expected: expected, + wantErr: "belongs to workflow run 200, expected 100", + wantSent: pubgh.ErrHandoffMismatch, + }, + { + name: "nil workflow-run metadata", + setup: func(meta *ghactmocks.MockArtifactMeta) { + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{ + ID: expected.Artifact, + Digest: expected.Digest, + }, nil). + Once() + }, + expected: expected, + wantErr: "no workflow-run metadata", + wantSent: pubgh.ErrHandoffMismatch, + }, + { + name: "digest mismatch", + setup: func(meta *ghactmocks.MockArtifactMeta) { + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{ + ID: expected.Artifact, + Digest: mustDigest(t, otherDigest), + HasRun: true, + Run: expected.Run, + }, nil). + Once() + }, + expected: expected, + wantErr: "expected " + validDigest + ", got " + otherDigest, + wantSent: pubgh.ErrHandoffMismatch, + }, + { + name: "absent artifact", + setup: func(meta *ghactmocks.MockArtifactMeta) { + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{}, errors.New("artifact not found")). + Once() + }, + expected: expected, + wantErr: "artifact not found", + }, + { + name: "cancelled context", + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + setup: func(_ *ghactmocks.MockArtifactMeta) {}, + expected: expected, + wantErr: context.Canceled.Error(), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + meta := ghactmocks.NewMockArtifactMeta(t) + test.setup(meta) + + ctx := context.Background() + if test.ctx != nil { + ctx = test.ctx() + } + + got, err := pubgh.VerifyHandoff(ctx, meta, test.expected, instantSleep) + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + if test.wantSent != nil { + require.ErrorIs(t, err, test.wantSent) + } + return + } + + require.NoError(t, err) + assert.Equal(t, expected.Artifact, got.ID) + assert.Equal(t, expected.Digest, got.Digest) + assert.Equal(t, expected.Run, got.Run) + }) + } +} + +func TestVerifyHandoffRejectsNilPort(t *testing.T) { + t.Parallel() + + _, err := pubgh.VerifyHandoff( + context.Background(), + nil, + mustHandoff(t, 1), + instantSleep, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "artifact metadata port is nil") +} + +func TestNewHandoffRejectsZeroValues(t *testing.T) { + t.Parallel() + + _, err := pubgh.NewHandoff(pubgh.Repository{}, 0, 0, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "repository is empty") +} + +// mustHandoff constructs a valid expected tuple or fails the test. +func mustHandoff(t *testing.T, run int64) pubgh.Handoff { + t.Helper() + + repo, err := pubgh.ParseRepository("meigma/release") + require.NoError(t, err) + runID, err := pubgh.RunIDFromInt(run) + require.NoError(t, err) + artifactID, err := pubgh.ArtifactIDFromInt(1) + require.NoError(t, err) + parsed, err := pubgh.ParseArtifactDigest(validDigest) + require.NoError(t, err) + handoff, err := pubgh.NewHandoff(repo, runID, artifactID, parsed) + require.NoError(t, err) + + return handoff +} + +// mustRunID constructs a RunID or fails the test. +func mustRunID(t *testing.T, value int64) pubgh.RunID { + t.Helper() + + id, err := pubgh.RunIDFromInt(value) + require.NoError(t, err) + + return id +} + +// mustDigest constructs an ArtifactDigest or fails the test. +func mustDigest(t *testing.T, value string) pubgh.ArtifactDigest { + t.Helper() + + digest, err := pubgh.ParseArtifactDigest(value) + require.NoError(t, err) + + return digest +} + +func TestArtifactIDFromIntRejectsUnsafe(t *testing.T) { + t.Parallel() + + _, err := pubgh.ArtifactIDFromInt(0) + require.Error(t, err) + _, err = pubgh.ArtifactIDFromInt(1 << 53) + require.Error(t, err) +} + +func TestVerifyHandoffWrapsPortError(t *testing.T) { + t.Parallel() + + expected := mustHandoff(t, 100) + meta := ghactmocks.NewMockArtifactMeta(t) + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{}, errors.New("boom")). + Once() + + _, err := pubgh.VerifyHandoff(context.Background(), meta, expected, instantSleep) + require.Error(t, err) + assert.Contains(t, err.Error(), "get artifact 1") + assert.Contains(t, err.Error(), "boom") +} + +func TestVerifyHandoffRetriesRetryableThenSucceeds(t *testing.T) { + t.Parallel() + + expected := mustHandoff(t, 100) + meta := ghactmocks.NewMockArtifactMeta(t) + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{}, pubgh.ErrRetryable). + Times(3) + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{ + ID: expected.Artifact, + Digest: expected.Digest, + HasRun: true, + Run: expected.Run, + }, nil). + Once() + + var waits []time.Duration + sleep := func(_ context.Context, d time.Duration) error { + waits = append(waits, d) + return nil + } + got, err := pubgh.VerifyHandoff(context.Background(), meta, expected, sleep) + require.NoError(t, err) + assert.Equal(t, expected.Artifact, got.ID) + assert.Equal(t, []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, waits) +} + +func TestVerifyHandoffRetryBudgetExhausted(t *testing.T) { + t.Parallel() + + expected := mustHandoff(t, 100) + meta := ghactmocks.NewMockArtifactMeta(t) + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{}, pubgh.ErrRetryable). + Times(4) + + var waits []time.Duration + sleep := func(_ context.Context, d time.Duration) error { + waits = append(waits, d) + return nil + } + _, err := pubgh.VerifyHandoff(context.Background(), meta, expected, sleep) + require.Error(t, err) + require.ErrorIs(t, err, pubgh.ErrRetryable) + assert.Contains(t, err.Error(), "after 4 attempts") + assert.Equal(t, []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, waits) +} + +func TestVerifyHandoffDoesNotRetryAbsent(t *testing.T) { + t.Parallel() + + expected := mustHandoff(t, 100) + meta := ghactmocks.NewMockArtifactMeta(t) + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{}, errors.New("artifact not found")). + Once() + + _, err := pubgh.VerifyHandoff(context.Background(), meta, expected, instantSleep) + require.Error(t, err) + assert.Contains(t, err.Error(), "artifact not found") + assert.NotErrorIs(t, err, pubgh.ErrRetryable) +} + +func TestVerifyHandoffCancelDuringBackoff(t *testing.T) { + t.Parallel() + + expected := mustHandoff(t, 100) + meta := ghactmocks.NewMockArtifactMeta(t) + meta.EXPECT(). + Get(mock.Anything, expected.Repository, expected.Artifact). + Return(pubgh.ArtifactMetadata{}, pubgh.ErrRetryable). + Once() + + ctx, cancel := context.WithCancel(context.Background()) + sleep := func(_ context.Context, _ time.Duration) error { + cancel() + return context.Canceled + } + _, err := pubgh.VerifyHandoff(ctx, meta, expected, sleep) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) +} + +// instantSleep is a SleepFunc that never waits. +func instantSleep(_ context.Context, _ time.Duration) error { + return nil +} diff --git a/mise.lock b/mise.lock index f8c0a67..fbb3446 100644 --- a/mise.lock +++ b/mise.lock @@ -180,6 +180,26 @@ checksum = "sha256:2347488e5d5b25336644024dfeca5601b190e91197a71a917bda44744aff1 url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-darwin-amd64" provenance = "cosign" +[[tools."aqua:vektra/mockery"]] +version = "3.7.3" +backend = "aqua:vektra/mockery" + +[tools."aqua:vektra/mockery"."platforms.linux-arm64"] +checksum = "sha256:01882151519384bf7877d0c63c0fc02a678ee5d9a2c6c8666675aa17a16744c4" +url = "https://github.com/vektra/mockery/releases/download/v3.7.3/mockery_3.7.3_Linux_arm64.tar.gz" + +[tools."aqua:vektra/mockery"."platforms.linux-x64"] +checksum = "sha256:150bbabc3c50ceb964b709fdfbc54e095e4f585a85ccfe2c8ee7f640d522f110" +url = "https://github.com/vektra/mockery/releases/download/v3.7.3/mockery_3.7.3_Linux_x86_64.tar.gz" + +[tools."aqua:vektra/mockery"."platforms.macos-arm64"] +checksum = "sha256:4886bfd00f82dfb0ccd8112aaf4e85af142b4c3e051635120ac73c3394b59ff4" +url = "https://github.com/vektra/mockery/releases/download/v3.7.3/mockery_3.7.3_Darwin_arm64.tar.gz" + +[tools."aqua:vektra/mockery"."platforms.macos-x64"] +checksum = "sha256:107abe6c7bbf1ecc3d5a2fa9f621ab8d12816367b8ce35d51fa1cbf556f9360b" +url = "https://github.com/vektra/mockery/releases/download/v3.7.3/mockery_3.7.3_Darwin_x86_64.tar.gz" + [[tools.go]] version = "1.26.6" backend = "core:go" diff --git a/mise.toml b/mise.toml index 677b4be..72f093f 100644 --- a/mise.toml +++ b/mise.toml @@ -10,6 +10,7 @@ python = "3.14.7" "aqua:chainguard-dev/melange" = "0.59.1" "aqua:chainguard-dev/apko" = "1.2.37" "aqua:sigstore/cosign" = "3.1.3" +"aqua:vektra/mockery" = "3.7.3" [env] GOTOOLCHAIN = "local" diff --git a/moon.yml b/moon.yml index 684d0ec..8d1f348 100644 --- a/moon.yml +++ b/moon.yml @@ -27,6 +27,10 @@ fileGroups: ciConfig: - 'moon.yml' - '.github/workflows/**/*.yml' + mockeryConfig: + - '.mockery.yml' + - 'mise.toml' + - 'mise.lock' workspace: inheritedTasks: @@ -76,6 +80,16 @@ tasks: options: cache: false + mocks: + command: 'mise exec -- bash scripts/check-mocks.sh' + inputs: + - 'scripts/check-mocks.sh' + - '@group(goSources)' + - '@group(mockeryConfig)' + - '@group(ciConfig)' + options: + cache: false + check: deps: - 'root:format' @@ -83,6 +97,7 @@ tasks: - 'root:build' - 'root:test' - 'root:protocol' + - 'root:mocks' inputs: - '@group(goSources)' - '@group(lintConfig)' diff --git a/scripts/check-mocks.sh b/scripts/check-mocks.sh new file mode 100755 index 0000000..6371d70 --- /dev/null +++ b/scripts/check-mocks.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Prove checked-in Mockery output matches the pinned generator. +# Snapshots existing generated mocks, regenerates in place with the +# pinned mockery, diffs bytes against the snapshot, then restores the +# tree. The compared set is whatever mockery leaves under +# internal/adapter/*/mocks/*.go after generation (except hand-maintained +# doc.go), keyed by full relative path so same-named files in different +# adapters cannot collide. + +set -euo pipefail + +root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd -- "$root" + +die() { printf 'check-mocks: %s\n' "$*" >&2; exit 1; } + +pin="$(sed -n 's/^"aqua:vektra\/mockery" = "\([^"]*\)"$/\1/p' mise.toml)" +pin_count="$(printf '%s\n' "$pin" | grep -c . || true)" +if [ "$pin_count" -ne 1 ]; then + die "expected exactly one aqua:vektra/mockery pin in mise.toml, found ${pin_count}" +fi +expected="v${pin}" + +version="$(mise exec -- mockery version | tr -d '[:space:]')" +if [ "$version" != "$expected" ]; then + die "expected mockery ${expected}, got ${version:-}" +fi + +list_generated() { + find internal/adapter -path 'internal/adapter/*/mocks/*.go' ! -name doc.go -type f | LC_ALL=C sort +} + +tmp="$(mktemp -d)" +restore() { + status=$? + if [ -d "${tmp}/tree" ]; then + # Restore snapshotted files and drop anything mockery created. + find "${tmp}/tree" -type f -print0 | while IFS= read -r -d '' staged; do + rel="${staged#"${tmp}/tree/"}" + mkdir -p "$(dirname "$rel")" + cp "$staged" "$rel" + done + list_generated | while IFS= read -r path; do + if [ ! -f "${tmp}/tree/${path}" ]; then + rm -f "$path" + fi + done + fi + rm -rf "$tmp" + exit "$status" +} +trap restore EXIT + +mkdir -p "${tmp}/tree" +list_generated | while IFS= read -r path; do + mkdir -p "${tmp}/tree/$(dirname "$path")" + cp "$path" "${tmp}/tree/${path}" +done + +mise exec -- mockery + +mapfile -t generated < <(list_generated) +if [ "${#generated[@]}" -eq 0 ]; then + die "mockery produced no generated mock files under internal/adapter/*/mocks/" +fi + +failed=0 +for path in "${generated[@]}"; do + staged="${tmp}/tree/${path}" + if [ ! -f "$staged" ]; then + printf 'check-mocks: mockery wrote %s but it was not present in the tree\n' "$path" >&2 + failed=1 + continue + fi + if ! cmp -s "$path" "$staged"; then + printf 'check-mocks: %s does not match mockery %s output\n' "$path" "$expected" >&2 + diff -u "$staged" "$path" >&2 || true + failed=1 + fi +done + +if [ "$failed" -ne 0 ]; then + exit 1 +fi