Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .mockery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ packages:
ArtifactMeta:
config:
filename: artifact_meta.go
github.com/meigma/release/internal/stage/puboci:
config:
dir: internal/adapter/reg/mocks
interfaces:
StateReader:
config:
filename: state_reader.go
10 changes: 10 additions & 0 deletions cmd/release-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
"syscall"

"github.com/meigma/release/internal/adapter/ghact"
"github.com/meigma/release/internal/adapter/reg"
"github.com/meigma/release/internal/cli"
"github.com/meigma/release/internal/stage/pubgh"
"github.com/meigma/release/internal/stage/puboci"
)

//nolint:gochecknoglobals // Linker-injected build metadata.
Expand All @@ -35,6 +37,14 @@ func run() int {
NewArtifactMeta: func(token string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) {
return ghact.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL)
},
NewStateReader: func(credentials cli.RegistryCredentials) (puboci.StateReader, error) {
return reg.New(reg.Options{
Credentials: reg.Credentials{
Username: credentials.Username,
Password: credentials.Password,
},
}), nil
},
Build: cli.BuildInfo{
Version: version,
Commit: commit,
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/oci-image-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ A stable release tag `vMAJOR.MINOR.PATCH` publishes:

The exact tag must resolve to the builder's expected OCI index digest after publication. Each eligible channel tag must resolve to that digest; an out-of-order or backport release leaves newer channel tags unchanged. The publisher resolves and validates every existing tag before uploading the image. A repository-wide publisher concurrency group prevents different release tags from planning and updating channels concurrently. Prerelease, build-metadata, malformed, branch, and untagged refs are rejected.

`release-cli plan tags` evaluates the same exact-tag and channel policy as the publisher's planning step. It can run independently to inspect the decisions for a candidate release. The publisher's existing `actions/github-script` planning step remains authoritative for publication in this release. The workflow does not call `plan tags`.

A direct `plan tags` invocation has no repository-wide concurrency lock. Two concurrent planners outside the publisher workflow can observe the same registry state and plan conflicting channel moves. Direct use therefore requires a single writer by convention.

Digest-pinned references are the durable consumer interface:

```text
Expand Down
116 changes: 108 additions & 8 deletions docs/reference/release-cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
| Command | Purpose |
| --- | --- |
| `release-cli stage --profile go --dist PATH [--json]` | Validate the staged Go release files under `PATH`. |
| `release-cli plan tags [--image IMAGE] [--version VERSION] --digest DIGEST [--json]` | Inspect the immutable exact tag and moving channel tags for an OCI release. |
| `release-cli verify handoff --artifact-id <n> --digest <sha256:...> [--json]` | Verify an Actions artifact's GitHub API metadata before download. |
| `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. |

Expand All @@ -25,7 +26,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`, `verify handoff`, or `version`. |
| `command` | The command path, such as `plan tags`, `stage`, `verify handoff`, or `version`. |
| `ok` | `true` when the command succeeds; otherwise `false`. |
| `result` | The command-specific result object. |

Expand All @@ -38,6 +39,36 @@ The `stage --json` result contains these fields:
| `binaries.<arch>.path` | string | Original `<dist-basename>/`-prefixed path from `artifacts.json`. |
| `binaries.<arch>.mode` | string | Observed permission bits in octal notation. |

For `plan tags --json`, `command` is exactly `plan tags`. The `result` object contains these fields:

| Field | JSON type | Value |
| --- | --- | --- |
| `image` | string | OCI image name whose tags were inspected. |
| `version` | string | Candidate stable release version. |
| `digest` | string | Candidate OCI index digest, normalized to lowercase with the `sha256:` prefix. |
| `tags` | array of strings | Tags with a `create` decision, in decision order. Tags with an `accept` or `retain` decision are omitted. |
| `decisions` | array of objects | Decision for the exact tag and each channel tag, in policy order. |
| `decisions[].tag` | string | Exact or channel tag that was evaluated. |
| `decisions[].scope` | string | Tag scope: `exact`, `minor`, `major`, or `latest`. |
| `decisions[].action` | string | Result: `create`, `accept`, or `retain`. |

For example, this result plans to apply the exact and minor tags, retain the major tag, and accept the existing `latest` tag:

```json
{
"image": "ghcr.io/owner/repo",
"version": "1.2.3",
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"tags": ["1.2.3", "1.2"],
"decisions": [
{"tag": "1.2.3", "scope": "exact", "action": "create"},
{"tag": "1.2", "scope": "minor", "action": "create"},
{"tag": "1", "scope": "major", "action": "retain"},
{"tag": "latest", "scope": "latest", "action": "accept"}
]
}
```

The `version --json` result contains exactly these fields:

| Field | JSON type | Value |
Expand Down Expand Up @@ -70,14 +101,15 @@ The `verify handoff --json` result contains this object:
| `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
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`.
After command-line parsing and dispatch succeed, a command or configuration
failure under `--json` sets `ok` to `false` and gives `result` one string field
named `error`. The command also returns its nonzero exit code. Configuration
failures return code `2` and still emit exactly one envelope. Only command-line
parse or dispatch failures skip the envelope. These include an unknown command
or flag, an invalid flag value, or the wrong number of arguments. The usage
error goes to stderr and the process exits with code `2`.

Without `--json`, a successful `stage` or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli <version> (<commit>, protocol <n>)` 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.
Without `--json`, a successful `plan tags`, `stage`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli <version> (<commit>, protocol <n>)` 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

Expand All @@ -89,6 +121,74 @@ Without `--json`, a successful `stage` or `verify handoff` command writes nothin

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.

## OCI tag planning

`release-cli plan tags` inspects the current registry state and returns the tag decisions for one candidate OCI index.

| Value | Flag | Environment variable | Default |
| --- | --- | --- | --- |
| Image | `--image` | `RELEASE_IMAGE` | `ghcr.io/<owner>/<repo>`, lowercased from `GITHUB_REPOSITORY`. |
| Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. |
| Digest | `--digest` | `RELEASE_DIGEST` | None. A digest is required. |
| JSON output | `--json` | `RELEASE_JSON` | Disabled. |

An explicitly set flag takes precedence over its environment variable. The derived default applies only when the corresponding flag and release environment variable are absent. The image must have the lowercase form `host/path[/path...]` without a tag or digest. The digest must have the `sha256:` prefix followed by 64 hexadecimal digits.

The command resolves registry credentials in this order:

| Credential | Resolution |
| --- | --- |
| Token | Nonempty `GITHUB_TOKEN`, then nonempty `GH_TOKEN`. |
| Username | Nonempty `GITHUB_ACTOR`, then `x-access-token`. |

If neither token is present, the command reads the registry anonymously. Anonymous reads work only for public packages.

Missing or invalid configuration exits with code `2`. Under `--json`, this failure still writes exactly one envelope with `ok` set to `false`. A planning or registry failure exits with code `1`.

`plan tags` performs registry reads only. It never writes a tag, blob, or manifest. The reusable publisher workflow still owns tag application in this release. Its existing `actions/github-script` tag planner remains authoritative for publication. The workflow does not call `plan tags` in this release. The command supports planning and inspection only.

### Tag policy

The candidate version must match this canonical stable-version grammar:

```text
^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$
```

The version has exactly three components. It has no `v` prefix, leading zeros, prerelease, or build metadata. Each component must fit in a 64-bit unsigned integer. The publisher workflow compares components with arbitrary-precision integers. The CLI's 64-bit limit is a deliberate fail-closed narrowing of that workflow behavior.

The exact tag is `MAJOR.MINOR.PATCH`:

| Current exact-tag state | Decision |
| --- | --- |
| The tag is absent. | `create`: apply the tag to the candidate digest. |
| The tag resolves to the candidate digest. | `accept`: leave the tag unchanged. |
| The tag resolves to another digest. | Fail with an immutable-tag conflict. |

The command then evaluates channels in this order:

| Channel tag | Scope | Required release line |
| --- | --- | --- |
| `MAJOR.MINOR` | `minor` | The current annotation must have the candidate's major and minor components. |
| `MAJOR` | `major` | The current annotation must have the candidate's major component. |
| `latest` | `latest` | No release-line check. |

An absent channel gets a `create` decision. A channel that already resolves to the candidate digest gets an `accept` decision. Otherwise, the command reads the current manifest's `org.opencontainers.image.version` annotation. A missing or invalid stable-version annotation fails planning. A minor or major channel outside its required release line also fails planning.

Failure ordering differs from the publisher workflow. The workflow checks the exact tag before it resolves any channel. The CLI collects the exact tag and all three channels before it decides the plan. If an immutable-tag conflict and a corrupt channel exist together, the CLI may report the channel failure instead of the immutable-tag conflict. Both planners refuse the plan, and the CLI exits with code `1`. Only the failure reported first can differ.

For a valid channel annotation on a different digest, the command compares the candidate version with the annotated version:

| Comparison | Decision |
| --- | --- |
| The candidate is newer. | `create`: move the channel to the candidate digest. |
| The candidate is older. | `retain`: keep the channel on the newer release. |
| The versions are equal. | Fail because equal versions on different digests are corrupt state. |

### Concurrency

The publisher workflow serializes tag planning and application with a repository-wide concurrency group. A direct `plan tags` invocation outside that workflow has no cross-run lock. Two concurrent planners can observe the same registry state and plan conflicting channel moves. Direct use therefore requires a single writer by convention.

## Actions artifact handoff

`verify handoff` reads the artifact metadata from the GitHub Actions API before any artifact download. It validates all of these conditions:
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@ module github.com/meigma/release
go 1.26.6

require (
github.com/google/go-containerregistry v0.21.9
github.com/google/go-github/v82 v82.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
oras.land/oras-go/v2 v2.6.2
)

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/opencontainers/go-digest v1.0.0 // 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
golang.org/x/sync v0.22.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
24 changes: 24 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,39 @@ 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/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw=
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
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-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs=
github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo=
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/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
Expand All @@ -31,8 +45,18 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
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=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
Loading