diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c318a80..a605321 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -67,7 +67,7 @@ nfpms: homepage: https://github.com/meigma/release maintainer: Meigma description: Exercise the Meigma release pipeline. - license: LicenseRef-Proprietary + license: Apache-2.0 OR MIT formats: - deb - rpm @@ -92,7 +92,7 @@ homebrew_casks: name: homebrew-tap homepage: https://github.com/meigma/release description: Release automation for Meigma projects - license: LicenseRef-Proprietary + license: Apache-2.0 OR MIT url: template: "https://github.com/meigma/release/releases/download/{{ .Tag }}/{{ .ArtifactName }}" skip_upload: true @@ -106,7 +106,7 @@ scoops: name: scoop-bucket homepage: https://github.com/meigma/release description: Release automation for Meigma projects - license: Proprietary + license: Apache-2.0 OR MIT url_template: "https://github.com/meigma/release/releases/download/{{ .Tag }}/{{ .ArtifactName }}" skip_upload: true diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..b7253a9 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Meigma + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..6dbc1f6 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Meigma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 69a6252..1231cf1 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,58 @@ -# Meigma release workflows +# Release workflows -This repository defines the reusable workflows and repository contracts that Meigma uses to build, sign, attest, and publish Go binaries through GitHub Releases, multi-architecture OCI images through GHCR, and native Linux packages through a static Cloudflare R2 repository. It also builds and publishes `release-cli`, which owns release verification and publication behavior. The repository's tagged release builds the CLI from its own source and supplies that binary to the reusable workflows. +This repository publishes `release-cli` and reusable GitHub Actions workflows +for releasing one static Go application from one repository. A release can +produce GitHub Release assets, a multi-architecture image in GHCR, Homebrew and +Scoop update pull requests, and signed DEB, RPM, and APK repositories in +Cloudflare R2. + +## Supported release + +The supported application contract is intentionally narrow: + +- one Go application and binary per repository; +- stable, unscoped `vMAJOR.MINOR.PATCH` tags; +- static Darwin, Linux, and Windows binaries for `amd64` and `arm64`; +- Linux `amd64` and `arm64` images at `ghcr.io//`; +- DEB, RPM, and APK packages; +- cask-only Homebrew taps; and +- root-layout Scoop buckets. + +Each consumer pins every reusable workflow and signer identity to one reviewed, +full `meigma/release` commit SHA. The workflows, setup action, and +`release-cli` at that commit form one release unit. + +## Release flow + +```text +Release Please pull request + -> stable tag and draft GitHub Release + -> build, sign, and verify release assets + -> build and verify the OCI image + -> publish and attest the image + -> attest assets and publish the GitHub Release + -> open reviewed Homebrew and Scoop pull requests + -> optionally request central native-package publication +``` + +Publication controls are disabled in the +[copyable Go example](examples/go-release/). Rehearse the complete build and +inspect its draft and workflow artifacts before enabling destinations. ## Documentation -- [Install `release-cli` with mise](docs/how-to/install-release-cli-with-mise.md) -- [Install `release-cli` with Nix](docs/how-to/install-release-cli-with-nix.md) -- [Configure GitHub releases](docs/how-to/configure-github-releases.md) -- [Configure OCI image publication](docs/how-to/configure-oci-images.md) -- [Rehearse and recover GitHub releases](docs/how-to/rehearse-and-recover-github-releases.md) -- [Upgrade GitHub release workflows](docs/how-to/upgrade-github-release-workflows.md) -- [Set up the shared package repository](docs/how-to/set-up-package-repository.md) -- [`release-cli` contract reference](docs/reference/release-cli-contract.md) -- [GitHub release contract reference](docs/reference/github-release-contract.md) -- [OCI image contract reference](docs/reference/oci-image-contract.md) -- [Package repository contract reference](docs/reference/package-repository-contract.md) -- [Copyable Go release example](examples/go-release/) -- [Copyable Nix consumer example](examples/nix-release-cli/) - -Consumer repositories call the reusable workflows at one full commit SHA. The current released pin is `0fc99489d31d400bc3f69d6636d60e7d3f3d0251` (`v0.1.3`). +- [Release your first Go application](docs/tutorials/release-your-first-go-application.md) +- [Prepare your GitHub organization](docs/how-to/prepare-your-github-organization.md) +- [Adopt the release workflows](docs/how-to/adopt-the-release-workflows.md) +- [Add Homebrew and Scoop](docs/how-to/add-homebrew-and-scoop.md) +- [Operate a native package repository](docs/how-to/operate-a-native-package-repository.md) +- [Operate and recover releases](docs/how-to/operate-and-recover-releases.md) +- [Install `release-cli`](docs/how-to/install-release-cli.md) +- [Release system reference](docs/reference/release-system.md) +- [`release-cli` reference](docs/reference/release-cli.md) +- [Architecture and trust](docs/explanation/architecture-and-trust.md) + +## License + +Licensed under either the [Apache License 2.0](LICENSE-APACHE) or the +[MIT License](LICENSE-MIT), at your option. diff --git a/apko.yaml b/apko.yaml index c6bf887..24f3ba2 100644 --- a/apko.yaml +++ b/apko.yaml @@ -30,4 +30,4 @@ annotations: org.opencontainers.image.title: release-cli org.opencontainers.image.description: Exercise the Meigma release pipeline. org.opencontainers.image.source: https://github.com/meigma/release - org.opencontainers.image.licenses: LicenseRef-Proprietary + org.opencontainers.image.licenses: Apache-2.0 OR MIT diff --git a/docs/explanation/architecture-and-trust.md b/docs/explanation/architecture-and-trust.md new file mode 100644 index 0000000..50faa36 --- /dev/null +++ b/docs/explanation/architecture-and-trust.md @@ -0,0 +1,216 @@ +# Architecture and trust + +The release system separates versioning, construction, verification, and +publication because those phases do not need the same authority. The split is a +security boundary and a recovery boundary: an artifact can be built and +inspected without a credential that can make it public, and a publisher must +verify its input before using a write credential. + +## Versioning creates the candidate, not the payload + +Release Please owns the release pull request, stable tag, notes, and initial +draft. It uses an adopter-owned GitHub App because an App-created tag can trigger +the downstream tag workflow and can be granted a narrow protected-tag bypass. +The release pipeline does not recreate these objects if they are missing. + +GoReleaser then builds payloads for the immutable candidate commit, but both its +release pipe and its changelog are disabled. That avoids two components +competing to create notes or mutate a release. The release publisher begins +from the draft that Release Please created and treats its tag and target commit +as inputs to verify. + +This division also makes a draft rehearsal faithful. The build, checksum, +image, and release convergence paths run against a real stable candidate, while +the final public mutations remain separately disabled. + +## Builders stop at verified artifacts + +The Go producer can read source and obtain an OIDC identity for checksum +signing. It cannot write GHCR or mutate a GitHub Release. The OCI builder can +read the consumer repository and Actions artifacts, but it has no registry, +attestation-write, or release credential. + +Those jobs upload bounded artifacts with an Actions artifact ID and transport +digest. A downstream job verifies the API metadata before download, the +download action verifies the artifact ZIP digest, and a content-specific command +verifies the extracted files. These checks answer different questions: +transport integrity does not establish that an archive set or OCI layout +satisfies its release contract. + +A single job with compilers and every publisher credential would be shorter. It +would also run source-controlled build tools inside the same boundary that can +change public release assets and image tags. The extra handoffs keep that +authority out of the build environment. + +## The caller is the permission ceiling + +Except for the OCI publisher, each reusable workflow starts with +`permissions: {}`. The OCI publisher declares `artifact-metadata: read` at +workflow scope so attestation subjects remain discoverable. In every case, the +calling job supplies the maximum token permissions; a called workflow cannot +elevate above that ceiling. + +The boundaries differ by effect: + +- the producer has content read and checksum-signing OIDC; +- the OCI builder has artifact and content read; +- the OCI publisher has package, attestation, and OIDC authority but no App + private key; +- the GitHub Release publisher can mint a short-lived, contents-scoped App token + but has no package-write permission; +- the Homebrew and Scoop publishers can mint a token scoped to one destination + with contents and pull-request writes; and +- the native package producer can dispatch a public release identity but cannot + read R2 or aggregate signing credentials. + +The GitHub Release job depends on OCI publication, so an enabled release remains +a draft when registry publication fails. Homebrew, Scoop, and native dispatch +depend on the GitHub Release job. The supported caller enables them only when +`publish-release` is also enabled; the native receiver independently rejects a +draft. The Homebrew and Scoop publishers do not check public release state, so +enabling them during a draft-only run could create controls that point at a +draft. + +## The CLI carries policy; YAML exposes platform capabilities + +Stateful release policy is implemented in `release-cli`: closed-set bundle +validation, tag-to-commit binding, draft discovery, asset convergence, package +policy, registry tag decisions, immutable object checks, and postconditions. +Keeping this logic in Go makes one tested implementation responsible for the +same rule across producer and publisher workflows. + +GitHub-specific capabilities remain visible in workflow YAML. `actions/attest` +uses the Actions runtime and job-level OIDC and attestation permissions. The +App private key is an input to `actions/create-github-app-token`; only its +short-lived result reaches the CLI. The CLI neither receives the App private +key nor mints installation tokens. + +This is not a claim that all policy belongs in the CLI. YAML remains the place +where GitHub evaluates permission ceilings, protected environments, action +pins, secret bindings, and job dependencies. The CLI owns rules that must be +consistent and testable across those jobs. + +## The CLI is part of the release unit + +A verifier is itself executable supply-chain input. Verifying only consumer +artifacts would leave the process that interprets and publishes them +unconstrained. + +The reusable workflows, sibling setup action, and CLI therefore ship as one +release unit. An external consumer pins each reusable workflow and signer +identity to one full commit SHA. The pinned workflow loads the setup action from +that commit; the action's stamped version selects the matching CLI release and +requires its version and protocol. + +This removes a compatibility matrix in which a caller might combine one +workflow revision, another setup action, and a CLI with different release +semantics. The cost is that a CLI fix cannot be adopted independently. That +cost is intentional: a CLI change can alter how unprivileged artifacts are +interpreted or how a privileged publisher mutates remote state. + +The checksum identity uses the full shared workflow URL and release-unit SHA. +Native package policy records that immutable value directly. The GitHub +attestation signer field names the shared workflow without a ref because the +attestation verifier separately binds the source tag and producer commit. This +lets `acme/widget` use a signer implemented in `meigma/release` without +pretending the workflow belongs to `acme/widget`. + +## OCI publication has a trust-metadata gap + +OCI registry work must occur on both sides of GitHub's attestation steps: + +```text +prepare digest-addressed image and signatures + -> GitHub provenance attestation + -> GitHub amd64 SBOM attestation + -> GitHub arm64 SBOM attestation + -> finalize exact and channel tags +``` + +Preparation validates the layout and expected index digest, reads current tag +state, pushes content by digest, verifies it, and recursively signs the index. +It does not create or move a tag. GitHub then creates attestations through its +native action. Finalization runs only after all three succeed. + +Tags are last because they are consumer-facing names. Applying `1.2.3`, `1.2`, +`1`, or `latest` before signatures and attestations exist would expose a window +where normal consumers can discover incomplete trust metadata. Untagged +digest-addressed content can remain after an interruption without changing a +name that tag-based consumers follow. + +The preparation result records registry observations, not a durable plan. +Attestation takes time, and registry state can change. Finalization re-reads +exact and channel tags, accepts state that is unchanged or already on the +candidate, rejects other drift, and recomputes the plan. This fresh-state rule +also makes a retry converge after a partial tag commit: completed candidate tags +are accepted, remaining tags are applied, and newer channels remain retained. + +## Public releases are the commit point for package-manager updates + +Homebrew and Scoop controls are generated beside the release bundle but excluded +from its signed public payload. The Actions artifact digest binds them during +handoff. Each package-manager publisher isolates its own control, verifies the +underlying signed bundle, restores only that control, and opens a destination +pull request. + +Direct writes or automatic merges would bypass the destination's own policy and +platform validation. A reviewed pull request lets the tap or bucket require its +validation workflow, inspect generated URLs, and retain an independent audit +record. The publisher's deterministic branch and exact-byte checks make reruns +convergent without granting it authority to approve or merge its output. + +The cost is a manual merge and a delay between the GitHub Release and package +manager availability. That delay is preferable to turning the producer token +into an unchecked default-branch writer. + +## Native package credentials stay central + +A producer publishes signed and attested DEB, RPM, and APK assets in its GitHub +Release, then dispatches only `{repository, tag}`. It does not receive the +Cloudflare token or aggregate APT, RPM, and APK signing keys. + +The adopter-owned central repository records package ownership, explicit shared +workflow identities, and producer public keys under review. Its protected +environment supplies bucket-scoped R2 credentials and aggregate private keys. +The central CLI independently verifies the release closed set, checksum signer, +GitHub attestations, package metadata, and producer-native signatures before it +uses those credentials. + +The publisher reconstructs repository metadata from existing immutable package +objects plus the incoming release. It uploads referenced inner objects before +APT, RPM, and APK roots. A crash can leave unreachable objects but cannot +activate a root that points at missing content. A single workflow concurrency +group serializes writes because R2 does not provide a multi-object repository +transaction. + +This creates a central signing and availability bottleneck. In return, producer +onboarding is a reviewed policy and public-key change rather than a grant of +shared production credentials. A replay can converge from current object state, +and an immutable conflict fails instead of silently replacing history. + +## One application per repository is a deliberate limit + +The repository name determines the GHCR image name, the caller has one stable +unscoped tag stream, the OCI layout has one entrypoint, and Release Please owns +one root manifest version. Supporting multiple applications would require +component-aware tags, separate asset namespaces, multiple image names, and more +complex ownership and recovery rules across every publisher. + +Keeping one application per repository avoids that cross-product. The tradeoff +is more repositories and repeated organization setup. The repeated unit is +operationally visible: each application has one App installation entry, one +release-unit SHA, one draft, one image, and one set of optional destinations. + +## Unsupported cases preserve these boundaries + +The system excludes prereleases, component tags, dynamic Linux binaries, custom +registries, mutable exact image tags, direct package-manager merges, producer +access to central credentials, and rollback after publication. These are not +missing conveniences around an otherwise compatible path. Each would change a +trust or naming assumption used by versioning, artifact verification, +publication ordering, or recovery. + +A public correction is therefore additive. The system does not move a public +tag, re-draft a release, replace an exact image tag, or overwrite an immutable +native package object. It publishes a new stable version whose complete release +unit can be reviewed and verified again. diff --git a/docs/explanation/release-trust-boundaries.md b/docs/explanation/release-trust-boundaries.md deleted file mode 100644 index 41b579e..0000000 --- a/docs/explanation/release-trust-boundaries.md +++ /dev/null @@ -1,193 +0,0 @@ -# Why release trust is split across workflows and the CLI - -A release begins as repository source and ends as public GitHub Release assets -and named OCI images. Those states do not require the same authority. The -release system therefore separates artifact production, artifact verification, -and publication instead of giving one job every credential and every policy -decision. - -The reusable workflows, the `setup-release-cli` composite action, and -`release-cli` each enforce a different part of that separation. Their boundaries -are also failure boundaries: an artifact can be built and inspected without -having the authority to publish it, and a publisher must verify what it received -before it uses its write permissions. - -## Producers stop at verified artifacts - -The Go producer and OCI builder are less privileged than the publishers. The Go -producer can read repository contents and obtain an OIDC identity for the -keyless signature on `checksums.txt`, but it has no package-write permission and -never receives the GitHub App release credential. The OCI builder can read the -source and Actions artifacts, but it has no package-write, attestation-write, or -release permission. - -These jobs turn source and configuration into bounded artifacts. The Go -producer calls `release-cli stage --profile go`; the CLI invokes GoReleaser, -validates the closed release bundle, and projects the canonical Linux binaries -for the image build. The OCI builder consumes those binaries, builds the signed -APK repositories and OCI layout, and verifies the completed layout before -uploading it. Neither job can use a successful build as authority to publish. - -The handoff carries an Actions artifact ID and digest rather than an unchecked -directory. Each downstream job uses `release-cli verify handoff` to compare the -artifact's GitHub API metadata with that expected identity before download, and -the download action also rejects a digest mismatch. Content-specific checks run -after download. This repetition is deliberate: transport integrity does not -establish that a release bundle or OCI layout satisfies its own contract. - -A single job with both build tools and publication credentials would be -shorter, but any build-time command would then run inside the publication trust -boundary. The split keeps compilers and consumer build configuration out of the -jobs that can change a public release or package tag. - -## The caller sets the permission ceiling - -Every reusable workflow declares the permissions its job needs, but a called -workflow cannot elevate itself beyond the permissions granted by the calling -job. The copyable caller starts with `permissions: {}` and grants permissions to -each job explicitly. This makes the caller a ceiling rather than a passive -forwarder of the callee's request. - -The distinction matters for both safety and operation. A producer cannot gain -`packages: write` merely because a future edit requests it inside the reusable -workflow. Consumer repositories also must grant `attestations: read` so the -setup action can verify the released CLI archive. The release repository builds -the CLI from its matching tag instead, but the reusable contract keeps the -permission needed by consumers. Missing permissions fail at that boundary. - -The publisher ceilings differ because their effects differ. The OCI publisher -has package, attestation, and OIDC permissions but receives no GitHub App key. -The GitHub Release publisher can create a short-lived App token for release -mutation but has no package-write permission. The top-level dependency from the -GitHub publisher to the OCI publisher also keeps the GitHub Release in draft -state when image publication fails. - -## The CLI is verified before it enforces release policy - -The CLI is itself part of the release supply chain. Verifying only the consumer -artifact would leave the verifier and publisher executable unauthenticated. -`setup-release-cli` therefore establishes the CLI's identity before any workflow -invokes it. - -When the release repository runs its own matching version tag, the composite -action builds `release-cli` from the source beside the pinned action. It requires -the runner-provided reusable workflow SHA, installs the Go patch version pinned -by the release unit, and stamps that SHA into the executable. Exact-key -`GOCACHE` and `GOMODCACHE` entries accelerate later jobs in the sequential -release graph. A cache miss remains a complete build, and the executable itself -is never restored from a cache. - -This source path removes the same-run CLI artifact handoff, but it deliberately -puts a Go compiler and source execution in each publishing job. The workflow SHA -and version/protocol check bind each executable to the release unit. The cache -is only an optimization and is not part of that identity. - -Consumer repositories use the installed path. The action derives the -distribution repository from `github.action_repository`, downloads the stamped -release archive and `checksums.txt`, requires one checksum entry for the -archive, and verifies its SHA-256 digest. It then runs `gh attestation verify` -against the release repository and its `publish-github-release.yml` signer, -with self-hosted runners denied, before extracting and executing the binary. - -Both supported paths require `release-cli version --json` to report the version -and protocol stamped into the action. Source identity or release provenance -answers where the executable came from; the version and protocol guard answers -whether it is the member of the release unit that the workflow expects. - -The action's optional `cli-path` input is an explicit escape from these models. -A caller-supplied binary is not built, downloaded, or attested, and a stamp -mismatch warns instead of failing. Direct action callers that use this input own -the workflow-to-binary pairing. - -## Policy lives in the CLI; platform capabilities stay in YAML - -Release and registry operations contain stateful policy that is easier to test -and keep consistent in Go than in workflow shell. `release-cli` owns the -GoReleaser invocation and release-bundle projection, closed-set bundle -verification, draft discovery and asset convergence, registry inspection, -digest uploads and signatures, immutable exact tags, channel movement, and -postcondition checks. The same command behavior applies wherever the CLI runs; -it is not reimplemented in four workflow files. - -Some capabilities cannot move into that process without weakening a different -boundary. `actions/attest` is a GitHub Action whose provenance and SBOM -operations use the Actions runtime and the job's attestation and OIDC -permissions. It remains a SHA-pinned YAML step rather than a second attestation -implementation inside the CLI. - -The GitHub App private key also remains an input to -`actions/create-github-app-token`. The workflow passes only the resulting -short-lived installation token to `release-cli publish github`. The CLI can use -the token to apply its release policy, but it neither receives the private key -nor mints an installation token. Keeping token creation visible in YAML also -keeps its required job permission and secret binding visible at the point where -GitHub evaluates them. - -The OCI path has a similar division. The CLI authenticates its registry client -in memory from the workflow token and owns registry decisions. The workflow -performs the GHCR login needed by Cosign and registry-backed `actions/attest` -steps. Moving all of this into shell would duplicate registry and release policy -outside the CLI. Moving all of it into Go would require replacing GitHub's -attestation action and recreating its Actions runtime integration. - -## OCI publication crosses the boundary in two phases - -OCI publication needs CLI-controlled registry work both before and after the -workflow-controlled attestation steps. The split prevents a consumer-visible -tag from naming the candidate before all required trust metadata exists, while -still letting GitHub's action create that metadata with its native job -permissions. - -[Why OCI publication has two phases](two-phase-oci-publication.md) describes the -prepare, attestation, and finalize transaction, including why tags are last and -why finalization reads fresh registry state. That transaction is one concrete -result of the broader trust split described here. - -## Package producers do not own repository credentials - -A native package producer stops after publishing signed and attested GitHub -Release assets. It sends the central package repository only its repository name -and exact release tag. The producer never receives the aggregate APT, RPM, or -APK signing keys and cannot write R2 objects. - -The central package repository holds reviewed producer ownership, workflow -identities, and public keys in Git. Its `packages-production` environment holds -the aggregate private keys and least-privilege R2 credentials. The reusable -workflow selects that environment, then the CLI independently verifies the -release checksums, Sigstore identity, GitHub attestations, package identity, and -native signatures before it uses the write credentials. - -The CLI regenerates metadata from all existing immutable package objects plus -the incoming release. It uploads non-root objects before APT `InRelease`, RPM -`repomd.xml`, and APK `APKINDEX.tar.gz` roots. This ordering keeps an incomplete -generation unreachable after a crash. One non-cancelling workflow concurrency -group serializes writers because R2 does not provide the repository transaction. - -This boundary makes producer onboarding a policy change rather than a credential -grant. The cost is one central bottleneck and one shared signing domain. The -reviewed allowlist, replayable request, and fail-closed immutable paths keep that -tradeoff explicit. - -## One release unit has one consumer pin - -The reusable workflows, their sibling setup actions, and the CLI ship as one -release unit. A consumer pins each reusable workflow and the -checksum signer identity to one full commit SHA. Inside the pinned workflow, -`uses: $/.github/actions/setup-release-cli` selects the action from that same -commit. The action's stamped version then selects the CLI release archive. -Release Please updates that stamp as an extra versioned file when it versions -the release repository. - -This coupling removes an otherwise large compatibility matrix. Consumers do -not choose one workflow revision, another setup action revision, and an -independent CLI version that may implement a different protocol. The setup -action's version and protocol check fails closed when the supported acquisition -path does not match the unit. - -The tradeoff is that the CLI cannot be upgraded independently to obtain one -fix. The whole unit must be reviewed, versioned, and pinned together. That cost -is intentional: a CLI change can alter how producer artifacts are interpreted -or how publisher credentials mutate remote state, so independent selection -would make the workflow pin an incomplete description of the release system. -A single full-SHA pin makes the reviewed workflows, acquisition logic, command -behavior, and signing identity one auditable choice. diff --git a/docs/explanation/two-phase-oci-publication.md b/docs/explanation/two-phase-oci-publication.md deleted file mode 100644 index 31a1f16..0000000 --- a/docs/explanation/two-phase-oci-publication.md +++ /dev/null @@ -1,56 +0,0 @@ -# Why OCI publication has two phases - -OCI publication is split across `release-cli` and the reusable GitHub Actions workflow because each has a different security boundary. The CLI owns registry state, digest-addressed content, signatures, and tags. The workflow owns the GitHub attestation actions and the job permissions that those actions require. - -The resulting sequence is: - -```text -release-cli publish oci prepare - -> actions/attest provenance - -> actions/attest amd64 SBOM - -> actions/attest arm64 SBOM - -> release-cli publish oci finalize -``` - -This order makes consumer-visible tags the commit point for a publication. - -## Why attestation separates prepare from finalize - -A GitHub Action cannot run inside a CLI process. `actions/attest` depends on the GitHub Actions runtime and on permissions granted to the workflow job, including permission to request an OIDC token, write attestations, and push registry-backed attestations. `release-cli` cannot acquire or hold those job-level permissions on its own. - -Moving attestation into the CLI would require a second, less direct implementation of the GitHub Actions integration. Moving all registry work into workflow scripts would duplicate the release policy outside the CLI. The two-phase design keeps each operation at the boundary that can perform and verify it. - -`prepare` validates the OCI layout and expected index digest, reads the current tags, pushes the image by digest, verifies the pushed manifests, and signs the index recursively. It never creates or moves a tag. The three `actions/attest` steps then attach provenance to the index and SBOM attestations to the two platform manifests. Only after all three steps succeed does `finalize` apply the planned exact and channel tags. - -## Why tags are last - -Release tags are the consumer-facing names of the image. An exact tag such as `1.2.3` makes a release discoverable, while channel tags such as `1.2`, `1`, and `latest` direct consumers to a selected release. Applying any of these tags before signing and attestation would create a period in which consumers could pull an image that does not yet have its required trust metadata. - -Digest-addressed content does not create that exposure. Pushing `image@sha256:...` without a tag does not change any release name that consumers follow. A party that already knows the digest and has registry access can address the content, but ordinary tag-based consumers cannot discover it as a release. This is why an interrupted prepare or attestation phase can leave registry content behind without changing the published release. - -Tags therefore act as a commit point rather than an upload mechanism. Before the commit point, the candidate can exist and accumulate trust metadata without replacing a consumer-visible reference. After the commit point, every newly applied tag names content that has already been pushed, verified, signed, and attested. - -## Why finalize reads the registry again - -The prepare result records what the registry contained before the digest upload. It is evidence for detecting a change, not a plan that `finalize` replays. Time passes while the workflow creates the three attestations, so the registry state seen by `prepare` may no longer be current when tag publication begins. - -`finalize` re-reads the exact tag and all channel tags. It compares that fresh state with the observations from `prepare`. An unchanged observation is valid. A tag that now resolves to the candidate index digest is also valid because it can be work completed by an earlier, partially successful finalization. Any other change is drift: another digest appeared, a version annotation changed, or a tag disappeared. `finalize` refuses that state instead of applying a decision derived from stale observations. - -After the drift check, `finalize` computes a new tag plan from the fresh state. It commits the required tags serially and verifies each result through an independent registry read. Serial commits preserve the policy order and avoid concurrent channel movement within one publication. - -This fresh-state rule is also what makes a rerun converge after a partial tag commit. Tags already applied to the candidate become accepted decisions, while tags that were not reached remain create decisions. Channels that correctly point to a newer release remain retained. The rerun does not assume that the first attempt made no progress. - -## Failure states - -A failed image publication does not always mean that the registry is unchanged. The phase that failed determines what an operator can infer. - -| Failure point | Registry state | Operator meaning | -| --- | --- | --- | -| Prepare validation or initial planning | No candidate tag is created or moved. The command can fail before any content write. | The candidate has not reached the trust-metadata sequence. Correct the invalid layout, configuration, immutable-tag conflict, or corrupt channel state before another workflow run. | -| Prepare upload, verification, or signing | Some digest-addressed blobs or manifests may exist, but no candidate tag is created or moved. | The candidate is not a consumer-visible release. A rerun can reuse content that the registry already has and must complete signing before attestation begins. | -| Provenance or SBOM attestation | The digest-addressed image is signed, and some attestations may exist, but no candidate tag is created or moved. | The trust metadata is incomplete. The workflow must complete all three attestation steps before finalization. | -| Finalize drift or planning refusal | The failing finalization writes no additional tags. Tags from an earlier partial attempt may already resolve to the candidate digest. | The registry changed outside the observations that preparation recorded, or current tag policy rejects the candidate. The refusal is a signal to inspect the named tag, not to force or hand-replay the saved prepare result. | -| Finalize commit | Trust metadata is complete, but only a prefix of the ordered tag set may resolve to the candidate digest. | Some consumers may see the exact release or a subset of its channels. Rerunning the publisher re-reads the registry, accepts the candidate tags already present, and applies the remaining eligible tags. | -| Finalize postcondition verification | Tags may have been written, but the CLI could not independently confirm the required resolutions. | Publication remains failed and the dependent GitHub Release remains draft. A rerun establishes fresh state and verifies the postcondition again. | - -The prepare envelope is deliberately not a durable receipt. It connects two phases within one workflow execution and supplies the observations used for drift detection. Replaying a saved envelope by hand would turn stale state into an input to a privileged tag operation, which defeats the fresh-state design. diff --git a/docs/how-to/add-homebrew-and-scoop.md b/docs/how-to/add-homebrew-and-scoop.md new file mode 100644 index 0000000..b7788ce --- /dev/null +++ b/docs/how-to/add-homebrew-and-scoop.md @@ -0,0 +1,319 @@ +# Add Homebrew and Scoop + +Use this guide to publish the GoReleaser-generated cask and Scoop manifest from +one producer into adopter-owned repositories. The publishers open pull requests. +They never merge, approve, or enable auto-merge. + +Complete [Adopt the release workflows](adopt-the-release-workflows.md) and +[Install `release-cli`](install-release-cli.md) first. Keep both publishers +disabled until their destination repositories and required checks exist. + +## Prepare the adopter-owned App + +Install the adopter-owned release App on: + +- the producer repository; +- the Homebrew tap; and +- the Scoop bucket. + +The App needs `contents: write` and `pull requests: write` on each destination. +The producer needs access to the App client-ID variable and private-key secret. +The tap and bucket do not need those Actions values; their validation workflows +are secret-free. + +## Set up the Homebrew tap + +### Generate the tap + +Choose a public `homebrew-` repository and an absent or empty local +directory: + +```bash +export TAP_REPOSITORY=acme/homebrew-tools +export TAP_DIRECTORY="$PWD/homebrew-tools" +release-cli init homebrew-tap \ + --tap "$TAP_REPOSITORY" \ + --output "$TAP_DIRECTORY" +find "$TAP_DIRECTORY" -type f -print | sort +``` + +The initializer writes exactly: + +```text +.github/dependabot.yml +.github/workflows/casks.yml +Casks/.gitkeep +README.md +``` + +It creates a cask-only tap and does not create `Formula/`. The generated +validation workflow pins `meigma/release` to the full source commit stamped into +the installed CLI. A nonempty output directory is rejected rather than merged. + +Create the public repository: + +```bash +cd "$TAP_DIRECTORY" +git init +git add . +git commit -m 'chore: initialize Homebrew tap' +git branch -M main +gh repo create "$TAP_REPOSITORY" --public --source=. --remote=origin --push +``` + +Enable Actions for the tap and allow the pinned `meigma/release` validation +workflow and its pinned actions. + +### Generate the cask in the producer + +Customize the maintained `homebrew_casks` entry in the producer's +`.goreleaser.yaml`: + +```yaml +homebrew_casks: + - name: widget + ids: + - widget + binaries: + - widget + repository: + owner: acme + name: homebrew-tools + homepage: https://github.com/acme/widget + description: Widget command + license: MIT + url: + template: "https://github.com/acme/widget/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + skip_upload: true +``` + +The cask name uses lowercase letters, digits, and interior hyphens. Keep +`skip_upload: true`: GoReleaser generates `dist/homebrew/Casks/widget.rb`, but +`release-cli publish homebrew` owns destination writes. + +Customize the maintained `homebrew-publish` job: + +```yaml + homebrew-publish: + name: Open Homebrew tap pull request + needs: + - release-assets + - github-release + permissions: + actions: read + attestations: read + contents: read + uses: meigma/release/.github/workflows/publish-homebrew.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + tap: acme/homebrew-tools + cask: widget + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-homebrew: false + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} +``` + +Replace both revision placeholders with the same full SHA used everywhere else +in the producer. Leave `publish-homebrew: false` until branch protection is +ready. + +### Protect and validate the tap + +In the tap's **Settings** > **Rules** > **Rulesets**, create an active branch +ruleset for `main` that: + +1. requires changes through a pull request; +2. requires `casks / Homebrew cask validation`; +3. blocks force pushes; and +4. blocks branch deletion. + +GitHub may not offer the required check until it has run once. If necessary, +enable the publisher for one real release, wait for the new tap pull request +and its validation run, add that check to the ruleset, and only then merge the +pull request. + +The validation workflow runs only when `Casks/**/*.rb` changes. Dependabot +changes only `.github/workflows/casks.yml`, so its pull request does not trigger +the required check. After the first cask exists, reproduce an action update on a +maintainer branch and include a semantically neutral comment change in that cask. +Before the first cask, defer the update rather than bypassing the ruleset. + +For the first publication, set `publish-homebrew: true` only in a caller that +also sets `publish-image: true` and `publish-release: true`, then create the +stable tag. After the GitHub Release is public, the publisher creates or reuses +the deterministic `release/widget/v` branch and opens one non-draft +pull request. Wait for validation, review the generated cask and URLs, and merge +it manually. + +Test the lifecycle on Apple silicon and Intel macOS when both archives are +published: + +```bash +brew install --cask acme/tools/widget +widget --version +brew update +brew upgrade --cask widget +widget --version +brew uninstall --cask widget +brew untap acme/tools +``` + +Publish and merge a later cask before testing `brew upgrade`. A failed producer +job does not merge the pull request. Correct the producer or tap configuration, +then rerun the failed job. + +## Set up the Scoop bucket + +### Generate the bucket + +Choose a public repository and an absent or empty local directory: + +```bash +export BUCKET_REPOSITORY=acme/scoop-tools +export BUCKET_DIRECTORY="$PWD/scoop-tools" +release-cli init scoop-bucket \ + --bucket "$BUCKET_REPOSITORY" \ + --output "$BUCKET_DIRECTORY" +find "$BUCKET_DIRECTORY" -type f -print | sort +``` + +The initializer writes exactly: + +```text +.gitattributes +.github/dependabot.yml +.github/workflows/manifests.yml +README.md +``` + +The bucket uses root `*.json` manifests. `.gitattributes` gives text files CRLF +line endings in Windows checkouts for the pinned Scoop tests. The initializer +does not create a sample manifest. + +Create the public repository: + +```bash +cd "$BUCKET_DIRECTORY" +git init +git add . +git commit -m 'chore: initialize Scoop bucket' +git branch -M main +gh repo create "$BUCKET_REPOSITORY" --public --source=. --remote=origin --push +``` + +Enable Actions and allow the generated workflow's pinned `meigma/release` +validation workflow and actions. + +### Generate the manifest in the producer + +Customize the maintained `scoops` entry: + +```yaml +scoops: + - name: widget + ids: + - widget + repository: + owner: acme + name: scoop-tools + homepage: https://github.com/acme/widget + description: Widget command + license: MIT + url_template: "https://github.com/acme/widget/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + skip_upload: true +``` + +Keep `skip_upload: true`. The archive configuration must let GoReleaser select +both Windows AMD64 and ARM64 assets when the producer ships both. + +Customize the maintained publisher job: + +```yaml + scoop-publish: + name: Open Scoop bucket pull request + needs: + - release-assets + - github-release + permissions: + actions: read + attestations: read + contents: read + uses: meigma/release/.github/workflows/publish-scoop.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + bucket: acme/scoop-tools + manifest: widget + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-scoop: false + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} +``` + +Replace both placeholders with the producer's one release-unit SHA. Leave +`publish-scoop: false` until the bucket ruleset is ready. + +### Protect and validate the bucket + +Create an active branch ruleset for `main` that: + +1. requires changes through a pull request; +2. requires `manifests / Scoop manifest validation`; +3. blocks force pushes; and +4. blocks branch deletion. + +GitHub may not offer the required check until it has run once. If necessary, +enable the publisher for one real release, wait for the new bucket pull request +and its validation run, add that check to the ruleset, and only then merge the +pull request. + +The validation workflow runs on root manifest changes and tests Windows AMD64 +and ARM64. Dependabot changes only `.github/workflows/manifests.yml`, so it does +not trigger this required check. After the first manifest exists, reproduce an +action update on a maintainer branch and include a semantically neutral format +change to one root manifest so validation runs. Before the first manifest, +defer that action update rather than bypassing the ruleset. + +Set `publish-scoop: true` only in a caller that also sets `publish-image: true` +and `publish-release: true`, then create the stable tag. After the GitHub +Release is public, review the generated root `widget.json`, wait for both +validation jobs, and merge the pull request manually. + +Test installation and removal on clean Windows AMD64 and ARM64 systems: + +```powershell +scoop bucket add scoop-tools https://github.com/acme/scoop-tools +scoop search widget +scoop install scoop-tools/widget +widget --version +scoop uninstall widget +scoop bucket rm scoop-tools +``` + +Because this bucket uses root manifests, `scoop bucket list` can report +`Manifests 0`. Use search, install, and the required validation check as the +acceptance signals. + +After publishing and merging a later version, test an update from the earlier +bucket commit: + +```powershell +scoop bucket add scoop-tools https://github.com/acme/scoop-tools +$bucket = Join-Path $env:USERPROFILE 'scoop\buckets\scoop-tools' +git -C $bucket checkout +scoop install scoop-tools/widget +git -C $bucket checkout main +git -C $bucket pull --ff-only +scoop update widget +widget --version +scoop uninstall widget +scoop bucket rm scoop-tools +``` + +Run the update lifecycle on both Windows architectures when both archives are +published. A failed publisher does not merge or auto-merge its pull request; +correct the conflict or configuration and rerun the failed job. diff --git a/docs/how-to/adopt-the-release-workflows.md b/docs/how-to/adopt-the-release-workflows.md new file mode 100644 index 0000000..e611434 --- /dev/null +++ b/docs/how-to/adopt-the-release-workflows.md @@ -0,0 +1,252 @@ +# Adopt the release workflows + +Use this guide to add the reusable release unit to an existing Go application +repository. Complete [Prepare your GitHub organization](prepare-your-github-organization.md) +first. + +The supported unit releases one application and one binary from one repository. +Use a separate repository and caller for each additional application. + +## Select one immutable release unit + +Choose a published `meigma/release` tag, review that release's workflow and +contract changes, and resolve the tag to a full commit SHA: + +```bash +export RELEASE_TAG="$(gh api repos/meigma/release/releases/latest --jq .tag_name)" +export RELEASE_REVISION="$(gh api "repos/meigma/release/commits/$RELEASE_TAG" --jq .sha)" +[[ "$RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] +test "$(gh api "repos/meigma/release/commits/$RELEASE_REVISION" --jq .sha)" = \ + "$RELEASE_REVISION" +printf '%s %s\n' "$RELEASE_TAG" "$RELEASE_REVISION" +``` + +Do not use a branch, moving tag, abbreviated SHA, or an independently selected +`release-cli` version. The full commit selects the reusable workflows, their +sibling setup action, the CLI release stamp, and the accepted signer identities. + +## Copy the maintained configuration + +From a checkout of this repository, copy the maintained example into the +producer. Do not overwrite existing project configuration. Merge applicable +settings by hand when a destination exists. + +```bash +export RELEASE_EXAMPLE=/absolute/path/to/release/examples/go-release +export PRODUCER=/absolute/path/to/widget +mkdir -p "$PRODUCER/.github/workflows" +cp "$RELEASE_EXAMPLE/.github/workflows/release-please.yml" \ + "$PRODUCER/.github/workflows/" +cp "$RELEASE_EXAMPLE/.github/workflows/release.yml" \ + "$PRODUCER/.github/workflows/" +cp "$RELEASE_EXAMPLE/.goreleaser.yaml" "$PRODUCER/" +cp "$RELEASE_EXAMPLE/apko.yaml" "$PRODUCER/" +cp "$RELEASE_EXAMPLE/melange.yaml" "$PRODUCER/" +cp "$RELEASE_EXAMPLE/.release-please-manifest.json" "$PRODUCER/" +cp "$RELEASE_EXAMPLE/release-please-config.json" "$PRODUCER/" +cp "$RELEASE_EXAMPLE/mise.toml" "$PRODUCER/" +cp "$RELEASE_EXAMPLE/mise.lock" "$PRODUCER/" +``` + +Keep the producer's existing `go.mod`, source, CI, review policy, and branch +protection. The example is release configuration, not a complete repository +policy. + +In `.github/workflows/release.yml`, replace every +`REPLACE_WITH_RELEASE_COMMIT_SHA` occurrence with `$RELEASE_REVISION`. Confirm +that no placeholder or second full-SHA workflow ref remains: + +```bash +cd "$PRODUCER" +! grep -R 'REPLACE_WITH_RELEASE_COMMIT_SHA' .github/workflows +refs="$(grep -Eo '@[0-9a-f]{40}' .github/workflows/release.yml | sort -u)" +test "$refs" = "@$RELEASE_REVISION" +``` + +Update all reusable workflow references and every +`checksum-signing-workflow-ref` together whenever the release unit changes. A +mixed revision is not a supported migration state. + +## Adapt the GoReleaser configuration + +Edit `.goreleaser.yaml` for the producer's command: + +- set `project_name`, build ID, archive ID, binary name, and `main` package; +- keep `CGO_ENABLED=0` only if the command is genuinely static on all supported + targets; +- keep Darwin, Linux, and Windows on `amd64` and `arm64`; +- update linker variables if the command does not define `main.version` and + `main.commit`; +- update vendor, homepage, maintainer, description, license, and installed + binary path; +- keep the nFPM ID as `release` if native package signing may be enabled; +- keep DEB, RPM, and APK generated from the canonical Linux builds; +- keep one SBOM for each archive and native package; +- keep `checksums.txt` and its keyless Cosign bundle; and +- keep both `changelog.disable: true` and `release.disable: true`. + +`release-cli stage --profile go --dist dist` invokes exactly: + +```text +goreleaser release --clean --skip=publish +``` + +The GoReleaser release pipe must remain disabled. Release Please owns the +notes, tag, and initial draft; the reusable publishers own remote mutation. + +The maintained `homebrew_casks` and `scoops` entries use `skip_upload: true`. +Set their source repository and generated control names now, even if those +publishers remain disabled. Replace `TAP` and `BUCKET` only after the adopter-owned +destination repositories exist. GoReleaser generates controls; it never writes +those repositories directly. + +## Configure Release Please + +Edit `release-please-config.json`: + +- set `package-name` to the application name; +- choose the first stable version in `initial-version`; +- keep `include-v-in-tag: true`; +- keep `include-component-in-tag: false`; +- keep `force-tag-creation: true`; and +- keep `draft: true`. + +Set `.` in `.release-please-manifest.json` to the latest released version +without `v`. Use `0.0.0` only for a repository with no prior release. + +If the default branch is not `main`, change the branch filter in +`.github/workflows/release-please.yml`. The version workflow uses the +adopter-owned App credentials configured in the organization guide. + +## Lock the release tools + +Merge the example's tool declarations into the producer's `mise.toml`. The +release path requires locked versions of: + +- Go; +- GoReleaser; +- Syft; +- Cosign; +- GitHub CLI; +- Melange; and +- apko. + +Regenerate the supported-platform lock after changing a declaration: + +```bash +mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64 +mise install --locked +mise exec -- goreleaser check +mise exec -- go list ./cmd/... +``` + +Commit `mise.toml` and `mise.lock` together. The called workflows do not install +an undeclared replacement when the lock is incomplete. + +## Adapt Melange and apko + +In `melange.yaml`: + +- use the application binary as the package name; +- keep `version: ${{vars.version}}`; +- keep `x86_64` and `aarch64`; +- replace the organization metadata and SPDX license expression; and +- install the staged `application` file at `/usr/bin/` with mode `0755` + and ownership `0:0`. + +In `apko.yaml`: + +- consume the same Melange package; +- set the entrypoint to `/usr/bin/`; +- keep `amd64` and `arm64`; +- keep numeric runtime user and group `65532`; and +- set title, description, source, and SPDX license annotations. + +The current example includes CA certificates. Keep them for a command that +makes TLS connections. Add other runtime files through apko packages rather +than copying the build environment into the image. + +The OCI builder packages the canonical staged Linux binaries. Do not compile a +second binary in Melange or apko. + +## Configure optional signing + +Both signing inputs default to `false` in the maintained caller. + +### Sign and notarize macOS archives + +The example GoReleaser configuration contains a guarded `notarize.macos` block. +Before setting `sign-and-notarize-macos: true`, add these repository or +organization secrets to the producer: + +- `MACOS_SIGN_P12`: base64-encoded Developer ID Application certificate; +- `MACOS_SIGN_PASSWORD`; +- `MACOS_NOTARY_KEY`: base64-encoded App Store Connect API private key; +- `MACOS_NOTARY_KEY_ID`; and +- `MACOS_NOTARY_ISSUER_ID`. + +Map all five secrets in the `release-assets` call. The workflow fails before +staging if any enabled credential is absent. Apple rejection or timeout also +fails before a publisher runs. + +### Sign RPM and APK packages + +Before setting `sign-native-packages: true`, add: + +- `RPM_SIGNING_KEY`: base64-encoded OpenPGP private key; +- `RPM_SIGNING_PASSPHRASE`; +- `APK_SIGNING_KEY`: base64-encoded RSA private key; and +- `APK_SIGNING_PASSPHRASE`. + +Map all four secrets in the `release-assets` call. Keep the `.goreleaser.yaml` +RPM and APK `key_file` expressions supplied by the example. The workflow writes +owner-only temporary key files, GoReleaser signs packages before checksums are +generated, and the workflow removes the files after staging. + +Give the corresponding public producer keys to the central package-repository +operator. Do not give that operator the producer private keys. + +## Rehearse before enabling publishers + +The copied caller leaves all remote publishers disabled: + +```yaml +publish-image: false +publish-release: false +publish-homebrew: false +publish-scoop: false +publish-package-repository: false +``` + +Submit the complete release configuration through review. After it reaches the +default branch, confirm that GitHub recognizes both workflows: + +```bash +export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +gh workflow view release-please.yml --repo "$REPOSITORY" +gh workflow view release.yml --repo "$REPOSITORY" +``` + +Follow [Operate and recover releases](operate-and-recover-releases.md) to create +a stable candidate, inspect the populated draft and OCI artifact, and recover +any failure. + +After the rehearsal passes, set `publish-image` and `publish-release` to `true` +in the same reviewed commit. Keep Homebrew, Scoop, and package-repository +publication disabled until their external repositories, App installations, +keys, environments, and required checks exist. + +A successful stable release publishes: + +- the verified closed asset set in one GitHub Release; +- `ghcr.io//` for Linux `amd64` and `arm64`; and +- the exact image tag plus eligible `MAJOR.MINOR`, `MAJOR`, and `latest` + channels. + +After the first image publication, confirm GHCR visibility as described in the +organization guide. Consumers that require repeatability must use the +`ghcr.io//@sha256:` output, not a moving channel tag. + +To release another application, repeat this guide in another repository. Do +not add a second command, component-prefixed tag, or second image name to the +same caller. diff --git a/docs/how-to/configure-github-releases.md b/docs/how-to/configure-github-releases.md deleted file mode 100644 index a14c918..0000000 --- a/docs/how-to/configure-github-releases.md +++ /dev/null @@ -1,303 +0,0 @@ -# Configure GitHub Releases - -Use this guide to add the shared Meigma Go release workflows to a repository. The [GitHub Release contract](../reference/github-release-contract.md) defines the reusable workflow inputs, permissions, artifacts, and failure behavior. - -The documented workflow revision is `0fc99489d31d400bc3f69d6636d60e7d3f3d0251` (`v0.1.3`). - -## Prerequisites - -Before you change the repository, confirm that: - -- the repository contains a Go command and uses `main` as its default branch, or you know which branch value to replace in the example; -- GitHub Actions is enabled and the repository's Actions policy permits calls to `meigma/release` and the pinned actions used by the shared workflows; -- you can create and merge pull requests in the consumer repository; -- an organization owner can manage the `meigma-release` GitHub App installation and organization Actions credentials; -- `mise`, Git, and GitHub CLI are installed locally; and -- GitHub CLI is authenticated for the consumer repository. - -From the consumer repository, record its name and check authentication: - -```bash -gh auth status -export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" -printf 'Repository: %s\nDefault branch: %s\n' "$REPOSITORY" "$DEFAULT_BRANCH" -``` - -The example workflows target `main`. If the command prints another default branch, replace `main` in `.github/workflows/release-please.yml` before you merge the configuration. - -## 1. Grant the Release App access - -An organization owner must complete this step before either release workflow runs. - -1. Open the Meigma organization settings in GitHub. -2. Open **Third-party access** > **GitHub Apps** > **Installed GitHub Apps**. -3. Configure the Meigma Release App. -4. Under **Repository access**, keep **Only select repositories** selected and add the consumer repository. -5. Save the installation. - -The installation must show the consumer repository in its selected repository list. Do not change the installation to **All repositories**. - -If a repository or organization ruleset restricts creation of `v*` tags, add the Meigma Release App as a bypass actor for that restriction. Keep the rule enabled for other actors. - -These are organization administration operations. GitHub CLI has no purpose-built command for changing an App installation's selected repositories, and an API request requires installation-management authorization. The commands in this guide therefore do not attempt that change. An organization owner must use the GitHub settings UI or an independently authorized administrative process. - -## 2. Grant the organization credentials - -In the Meigma organization settings, open **Secrets and variables** > **Actions**. - -1. Create or update the organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`. -2. Set its value to the Meigma Release App client ID. -3. Set its repository access to **Selected repositories** and add the consumer repository. -4. Create or update the organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY` with the App private key. -5. Set the secret's repository access to **Selected repositories** and add the same consumer repository. - -The variable and secret must each show the consumer repository in their selected repository list. GitHub never returns an Actions secret's stored value, so verification is limited to its name, visibility, selected repository access, and a workflow that successfully creates an App token. Do not print the private key or add it as a repository file. - -The publisher workflow uses the client ID and private key with `actions/create-github-app-token`. It passes only the resulting short-lived installation token to `release-cli publish github` through `RELEASE_APP_TOKEN`. The CLI holds the token as a redacted secret; it never receives the App private key or mints a token. - -Organization owners can administer organization variables and secrets through GitHub CLI only when their token has the required organization scopes and role. This guide uses the UI because repository-level authorization alone cannot perform or verify these organization-level writes. - -## 3. Copy the release files - -From a checkout of `meigma/release`, copy the release infrastructure from `examples/go-release/` into the consumer repository. Preserve the relative paths. Do not copy `examples/go-release/README.md`. - -Set `CONSUMER` to the consumer checkout. Before copying, check whether any destination path already exists. If it does, stop and merge the example's release settings into that file; do not overwrite repository configuration. When the destination paths are absent, copy the files: - -```bash -export CONSUMER=/absolute/path/to/consumer -mkdir -p "$CONSUMER/.github/workflows" -cp examples/go-release/.github/workflows/release-please.yml "$CONSUMER/.github/workflows/" -cp examples/go-release/.github/workflows/release.yml "$CONSUMER/.github/workflows/" -cp examples/go-release/.goreleaser.yaml "$CONSUMER/" -cp examples/go-release/apko.yaml "$CONSUMER/" -cp examples/go-release/melange.yaml "$CONSUMER/" -cp examples/go-release/.release-please-manifest.json "$CONSUMER/" -cp examples/go-release/release-please-config.json "$CONSUMER/" -cp examples/go-release/mise.toml "$CONSUMER/" -cp examples/go-release/mise.lock "$CONSUMER/" -``` - -The example contains release infrastructure only. It does not define pull request checks, branch protection, code review, or the repository's complete CI policy. - -For a new empty repository that will use the complete minimal command, also copy: - -```bash -mkdir -p "$CONSUMER/cmd/example" -cp examples/go-release/go.mod "$CONSUMER/" -cp examples/go-release/cmd/example/main.go "$CONSUMER/cmd/example/" -``` - -Do not overwrite an existing repository's `go.mod` or command source. Adapt its real command to the copied release configuration instead. - -## 4. Replace project-specific values - -In the copied files, replace the example values with values from the consumer repository: - -- In `.goreleaser.yaml`, replace project name, build ID, archive ID, nFPM ID, command path, binary name, vendor, homepage, maintainer, description, and license with the consumer's values. -- Keep the `main.version` and `main.commit` linker variable names only if the command's `main` package defines both variables and uses them for `--version`. Otherwise, change the ldflags to the consumer command's real linker variables. -- If you copied the sample source, replace module path `example.com/meigma/release-consumer` and the literal command name and output in `cmd/example/main.go`. -- In `release-please-config.json`, replace package name `example` and choose the intended first release in `initial-version`. -- In `.release-please-manifest.json`, keep `0.0.0` only for a repository that has never released. For an existing project, set `.` to its latest released version without the `v` prefix. -- In `.github/workflows/release-please.yml`, replace `main` if the consumer's default branch is different. -- In `melange.yaml` and `apko.yaml`, replace package name, vendor, homepage, maintainer, command path, description, license, source URL, and image annotations as described in [Configure OCI image publication](configure-oci-images.md). - -Do not replace these shared contract values: - -- reusable workflow revision `0fc99489d31d400bc3f69d6636d60e7d3f3d0251`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251`; -- variable name `MEIGMA_RELEASE_APP_CLIENT_ID`; or -- secret name `MEIGMA_RELEASE_APP_PRIVATE_KEY`. - -To change the immutable revision later, follow [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). Update all reusable workflow references and the checksum signing identity together; do not edit one reference in isolation. - -Keep `checksum-signing-workflow-ref` in `owner/repository/workflow@revision` form without a URL prefix. The publisher adds `https://github.com/` and passes the resulting exact certificate identity to `release-cli verify bundle` with `--identity`. For example, the documented input becomes `--identity https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251`. - -The copied GoReleaser configuration builds Darwin, Linux, and Windows archives -for amd64 and arm64. It also packages each canonical Linux binary as DEB, RPM, -and APK without rebuilding it. Confirm that the consumer command supports those -targets before releasing it. Keep both `changelog.disable: true` and -`release.disable: true`: Release Please owns the release notes and initial -draft, and GoReleaser must not publish a release. - -The producer obtains `release-cli` through the shared setup action and runs -`release-cli stage --profile go --dist dist` before uploading either Actions -artifact. The `stage` command builds the release bundle by invoking exactly -`goreleaser release --clean --skip=publish`, then validates and projects the -result. `release.disable: true` and `--skip=publish` are independent publication -controls. Do not add a separate CLI path or version to the reusable workflow -calls. A consumer repository automatically installs the verified CLI release -stamped into the pinned workflow revision. - -After downloading the authoritative artifact, the publisher runs `release-cli verify bundle`. The command verifies the local closed file set before it verifies the detached Sigstore bundle against the exact certificate identity. The workflow then creates the GitHub build-provenance attestation with `dist/checksums.txt` and runs `release-cli publish github --dist dist --json`. The CLI rebuilds the expected names and digests from the verified local bundle, reconciles the matching draft, uploads expected names, and verifies GitHub's asset states and digests. Keep this verify, attest, then publish order. For the reasoning behind these separate responsibilities, see [Why release trust is split across workflows and the CLI](../explanation/release-trust-boundaries.md). - -The copied release caller sets both `publish-image: false` and `publish-release: false`. Keep both values for the first rehearsal. With `publish-release: false`, the workflow passes `--no-undraft`; the CLI converges the populated draft and stops without making it public. Before a public release, change both inputs to `true` and merge the change before Release Please creates the tag. The [rehearsal and recovery guide](rehearse-and-recover-github-releases.md) gives the safer first-run sequence. - -## 5. Generate and validate the tool lock - -Run the following commands from the consumer repository: - -```bash -mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64 -mise install --locked -mise exec -- goreleaser check -mise exec -- go list ./cmd/... -``` - -`mise lock` must leave `mise.lock` with entries for the pinned Go, GoReleaser, Syft, Cosign, GitHub CLI, Melange, and apko tools. `mise install --locked` must complete without changing a requested version, and `goreleaser check` must accept `.goreleaser.yaml`. Confirm that `go list` includes the command path configured in `.goreleaser.yaml`. - -Commit both `mise.toml` and the generated `mise.lock` with the other release files. Submit the change through the repository's normal pull request review and squash-merge process. - -After the configuration reaches the default branch, confirm that GitHub recognizes both workflows: - -```bash -gh workflow view release-please.yml --repo "$REPOSITORY" -gh workflow view release.yml --repo "$REPOSITORY" -``` - -Each command must print the corresponding workflow instead of reporting that the workflow was not found. - -## 6. Run Release Please - -Before continuing with a public release, confirm that `.github/workflows/release.yml` on the default branch contains both `publish-image: true` and `publish-release: true`. Release Please also needs at least one releasable Conventional Commit after the version recorded in `.release-please-manifest.json`. Do not create an empty release commit to satisfy this condition. - -When a releasable change is present, dispatch Release Please and inspect its run: - -```bash -gh workflow run release-please.yml --repo "$REPOSITORY" --ref "$DEFAULT_BRANCH" -gh run list \ - --repo "$REPOSITORY" \ - --workflow release-please.yml \ - --limit 5 -``` - -The successful run creates or updates one Release Please pull request. Find it by its workflow-managed label: - -```bash -export RELEASE_PR="$(gh pr list \ - --repo "$REPOSITORY" \ - --label 'autorelease: pending' \ - --json number \ - --jq '.[0].number')" -test -n "$RELEASE_PR" -gh pr view "$RELEASE_PR" --repo "$REPOSITORY" -``` - -Review the version, changelog, and manifest changes. If they are correct and required checks pass, squash-merge the release pull request: - -```bash -gh pr merge "$RELEASE_PR" \ - --repo "$REPOSITORY" \ - --squash \ - --delete-branch -``` - -The merge triggers Release Please again. A successful run creates a `v*` tag and a matching draft GitHub Release through the Release App. The App-created tag then triggers `.github/workflows/release.yml`. - -Inspect both workflows: - -```bash -gh run list --repo "$REPOSITORY" --workflow release-please.yml --limit 5 -gh run list --repo "$REPOSITORY" --workflow release.yml --limit 5 -``` - -With both publishers enabled, the Release workflow builds the authoritative archives and OCI image, verifies their handoffs, and publishes and signs the GHCR image. For the GitHub Release, the publisher runs `release-cli verify bundle`, creates the release attestation with `dist/checksums.txt`, and then runs `release-cli publish github`. The CLI binds the tag to the workflow commit, uploads only expected assets with clobber semantics, verifies the exact asset set and GitHub-reported digests, and makes the draft public as its last mutation. It never creates or re-drafts a release and never deletes an asset. - -For an unmodified new example, the first tag is `v0.1.0`. For another repository, set `TAG` to the exact tag shown by the successful Release Please run: - -```bash -export TAG=v0.1.0 -gh release view "$TAG" \ - --repo "$REPOSITORY" \ - --json tagName,isDraft,isPrerelease,publishedAt,url -``` - -The final result must report the expected tag, `"isDraft": false`, and `"isPrerelease": false`. The release contains six platform archives, six native Linux packages, twelve SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`. - -To stop before publication and inspect the populated draft, follow [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). - -## 7. Verify the published release - -Create a new directory and download the exact release: - -```bash -export ASSET_DIR="release-assets-${TAG#v}" -test ! -e "$ASSET_DIR" -mkdir "$ASSET_DIR" -gh release download "$TAG" \ - --repo "$REPOSITORY" \ - --dir "$ASSET_DIR" -cd "$ASSET_DIR" -``` - -On macOS, verify every payload named by the checksum manifest: - -```bash -shasum -a 256 --check checksums.txt -``` - -On Linux, use: - -```bash -sha256sum --check checksums.txt -``` - -Every listed archive, native package, and SBOM must report `OK`. - -Verify that the checksum manifest was signed by the canonical reusable pre-publish workflow revision: - -```bash -mise exec -- cosign verify-blob \ - --bundle checksums.txt.sigstore.json \ - --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251' \ - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ - checksums.txt -``` - -Cosign must exit successfully with the certificate identity and issuer constraints in place. - -Finally, verify the GitHub build-provenance attestation for every checksummed payload: - -```bash -while IFS= read -r entry; do - test -n "$entry" || continue - asset="${entry:66}" - mise exec -- gh attestation verify "$asset" \ - --repo "$REPOSITORY" \ - --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ - --signer-digest 0fc99489d31d400bc3f69d6636d60e7d3f3d0251 \ - --source-ref "refs/tags/$TAG" \ - --deny-self-hosted-runners -done < checksums.txt -``` - -Each invocation must exit successfully. The signer workflow names the reusable publisher rather than the consumer caller, and `--signer-digest` binds it to the canonical revision. GitHub attestations cover the 24 payloads in `checksums.txt`; the checksum manifest and Cosign bundle are control files and are not attestation subjects. - -## 8. Install a native Linux package - -Choose the package for the host architecture from the downloaded release -directory. Package filenames follow -`__linux_.`. - -Install the DEB on Debian or Ubuntu: - -```bash -sudo dpkg -i "__linux_.deb" -``` - -Install the RPM on Fedora or another RPM-based distribution: - -```bash -sudo rpm -i "__linux_.rpm" -``` - -Install the APK on Alpine: - -```bash -sudo apk add --allow-untrusted "__linux_.apk" -``` - -Run the installed command's version or health check after installation. These -standalone packages are not signed with a native package-manager key and are -not distributed through a package repository. Verify `checksums.txt`, its -Cosign signature, and the package's GitHub attestation before installation. diff --git a/docs/how-to/configure-oci-images.md b/docs/how-to/configure-oci-images.md deleted file mode 100644 index 7f6817d..0000000 --- a/docs/how-to/configure-oci-images.md +++ /dev/null @@ -1,274 +0,0 @@ -# Configure OCI image publication - -Use this guide to add signed, multi-architecture GHCR images to a repository that already uses the Meigma Go release workflows. The [OCI image contract](../reference/oci-image-contract.md) defines the reusable workflow interfaces, image contents, tags, signatures, attestations, and recovery behavior. - -The documented workflow revision is `0fc99489d31d400bc3f69d6636d60e7d3f3d0251` (`v0.1.3`). - -## Prerequisites - -Before changing the consumer repository, confirm that: - -- [Configure GitHub Releases](configure-github-releases.md) is complete; -- the Go command builds as a static Linux binary for both `amd64` and `arm64`; -- GitHub Actions policy permits the pinned Meigma workflows and actions; -- the repository's workflow token policy permits `packages: write`; -- GitHub Packages is enabled for the organization; -- `mise`, Git, GitHub CLI, ORAS, Cosign, and Docker are available for local verification; and -- GitHub CLI is authenticated with package read access. - -Record the consumer repository and immutable workflow revision: - -```bash -export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export RELEASE_REVISION=0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -export IMAGE="ghcr.io/${REPOSITORY,,}" -``` - -The publisher does not accept a custom image name. A repository named `OWNER/REPOSITORY` publishes `ghcr.io/owner/repository`. - -## 1. Copy the image configuration - -From a checkout of `meigma/release`, copy the example configuration into the consumer repository. Stop and merge by hand if either destination already exists. - -```bash -export CONSUMER=/absolute/path/to/consumer -test ! -e "$CONSUMER/melange.yaml" -test ! -e "$CONSUMER/apko.yaml" -cp examples/go-release/melange.yaml "$CONSUMER/" -cp examples/go-release/apko.yaml "$CONSUMER/" -``` - -The shared builder runs `release-cli image build` and then `release-cli image verify`. The workflow passes its `melange-config` and `apko-config` inputs to `image build`; their defaults remain `melange.yaml` and `apko.yaml`. `image build` uses Melange to package the canonical GoReleaser Linux binaries as signed APKs and apko to compose one OCI index for `linux/amd64` and `linux/arm64`. It does not compile the command again. `image verify` checks the layout, runtime contract, architecture SBOMs, and exact OCI index bytes before the workflow uploads the authoritative artifact. The workflow delegates these checks to the CLI instead of implementing them in shell. - -Ensure the consumer's `mise.toml` and `mise.lock` contain the tool versions required by the target workflow revision. The current workflow pins Melange `0.59.1` and apko `1.2.37`, resolves both executable paths with `mise which`, and supplies them to `release-cli image build` through `RELEASE_MELANGE_PATH` and `RELEASE_APKO_PATH`. - -## 2. Set project values - -Edit `melange.yaml`: - -- replace package name `example` with the command's binary name; -- replace the description; -- replace `LicenseRef-Proprietary` with the repository's SPDX license expression; and -- replace `/usr/bin/example` with the intended image command path. - -Keep these contract values: - -- `version: ${{vars.version}}`; -- target architectures `x86_64` and `aarch64`; -- the Wolfi repository and keyring; -- installation mode `0755`; and -- installation ownership `0:0`. - -Edit `apko.yaml`: - -- replace package name `example` with the Melange package name; -- replace `/usr/bin/example` with the installed command path; -- replace the title and description annotations; -- replace `https://github.com/OWNER/REPOSITORY` with the consumer repository URL; and -- replace `LicenseRef-Proprietary` with the repository's SPDX license expression. - -Keep these contract values: - -- the `nonroot` user and group at ID `65532`; -- `run-as: nonroot`; -- architectures `amd64` and `arm64`; -- the CA certificate package and `SSL_CERT_FILE`; and -- `/usr/bin` in `PATH`. - -Do not add a compiler or source build to either configuration. The upstream `release-cli stage --profile go` command invokes GoReleaser and selects the authoritative executables in the verified `oci-build-inputs` artifact. - -## 3. Add the builder and publisher jobs - -Use the complete caller in `examples/go-release/.github/workflows/release.yml` as the source. The image path consists of two jobs: - -1. `oci-image` calls `go-oci-build.yml` with the `oci-build-inputs` artifact ID and digest exposed by the `release-assets` job. -2. `oci-publish` calls `publish-oci-image.yml` with the authoritative OCI artifact ID, artifact digest, and image index digest from `oci-image`. - -The builder job must grant these permissions: - -```yaml -permissions: - actions: read - attestations: read - contents: read -``` - -`attestations: read` is required because the builder installs `release-cli` and -verifies its attestation. A called workflow can never request more than the -calling job grants, so omitting it fails the run before any job starts. - -The publisher job must grant only these permissions: - -```yaml -permissions: - actions: read - artifact-metadata: write - attestations: write - contents: read - id-token: write - packages: write -``` - -Pin every reusable workflow to the same full revision: - -```text -0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -``` - -Make `github-release` depend on `oci-publish`. That ordering keeps the GitHub Release in draft state when registry publication, signing, or attestation fails. - -## 4. Rehearse without registry writes - -Keep both publication controls disabled for the first tag rehearsal: - -```yaml -publish-image: false -publish-release: false -``` - -The run still executes `release-cli image build` to build the APK repository and OCI index and to verify the projected canonical binary digests. It then runs `release-cli image verify` to check the layout, runtime contract, architecture SBOMs, and exact index digest before upload. The publisher validates the artifact handoff and runs `release-cli publish oci prepare --dry-run`. The dry run validates the layout, digest, registry state, and tag plan without a registry write. It does not log in to GHCR, create tags, sign an image, create OCI attestations, or publish the GitHub Release. The publisher's `image-reference` output remains empty. - -Inspect the `oci-image` workflow artifact. It must contain: - -```text -apko.lock.json -apk-signing.rsa.pub -configuration/apko.yaml -configuration/melange.yaml -image-digest.txt -layout/index.json -layout/oci-layout -layout/blobs/sha256/* -packages/aarch64/* -packages/x86_64/* -sboms/sbom-aarch64.spdx.json -sboms/sbom-x86_64.spdx.json -``` - -Follow [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md) for the tag and draft procedure. - -## 5. Publish the image - -After the rehearsal passes, change both controls in the same reviewed commit: - -```yaml -publish-image: true -publish-release: true -``` - -Create the next stable `vMAJOR.MINOR.PATCH` release through Release Please. The image publisher rejects non-stable tags. - -The publisher runs `release-cli publish oci prepare` to push, verify, and recursively sign the image by digest without creating tags. It then runs the three GitHub attestation actions for index provenance and the two platform SBOMs. After all three attestations succeed, `release-cli publish oci finalize` re-reads registry state, applies eligible tags serially, and verifies their resolutions. See [Why OCI publication has two phases](../explanation/two-phase-oci-publication.md) for why tags are last. - -A successful `v1.2.3` run always publishes the immutable exact tag: - -```text -1.2.3 -``` - -It also advances `1.2`, `1`, and `latest` when `1.2.3` is newer than each channel's current stable version. An out-of-order or backport release publishes its exact tag and advances only the channels for which it is newer; it never moves a channel backward. - -`release-cli publish oci prepare` enforces exact-tag immutability before uploading registry content. If `1.2.3` already resolves to a different digest, preparation fails without writing the candidate image. Consumers that require repeatable deployment must use `ghcr.io/owner/repository@sha256:...`, not a moving tag. - -Package visibility follows the organization's package-creation setting; it does not inherit repository visibility. After the first complete publication, inspect the package: - -```bash -gh api "orgs/${REPOSITORY%%/*}/packages/container/${REPOSITORY#*/}" --jq .visibility -``` - -The required delivery state is `public`. If the result is `private`, an organization owner must inspect the signed and attested image, then use the package settings page to change its visibility to **Public**. GitHub does not expose a supported Packages REST operation for this visibility change. Until it is public, anonymous pulls fail. - -## 6. Verify the published image - -Set the release tag and authenticate ORAS. Authentication is required while the package remains private. - -```bash -export TAG=v1.2.3 -gh auth token | oras login ghcr.io --username "$(gh api user --jq .login)" --password-stdin -export DIGEST="$(oras resolve "$IMAGE:${TAG#v}")" -test "$DIGEST" = "$(oras resolve "$IMAGE:latest")" -printf 'Image: %s@%s\n' "$IMAGE" "$DIGEST" -``` - -Verify the keyless Cosign signature against the reusable publisher identity: - -```bash -cosign verify \ - --certificate-identity "https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@$RELEASE_REVISION" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$IMAGE@$DIGEST" -``` - -Verify the GitHub provenance attestation from both GitHub and the registry: - -```bash -gh attestation verify "oci://$IMAGE@$DIGEST" \ - --repo "$REPOSITORY" \ - --signer-workflow meigma/release/.github/workflows/publish-oci-image.yml \ - --signer-digest "$RELEASE_REVISION" \ - --source-ref "refs/tags/$TAG" \ - --deny-self-hosted-runners - -gh attestation verify "oci://$IMAGE@$DIGEST" \ - --repo "$REPOSITORY" \ - --bundle-from-oci \ - --signer-workflow meigma/release/.github/workflows/publish-oci-image.yml \ - --signer-digest "$RELEASE_REVISION" \ - --source-ref "refs/tags/$TAG" \ - --deny-self-hosted-runners -``` - -Verify each platform signature and SBOM attestation: - -```bash -for ARCH in amd64 arm64; do - PLATFORM_DIGEST="$( - oras manifest fetch "$IMAGE@$DIGEST" | - jq -r --arg arch "$ARCH" \ - '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch) | .digest' - )" - cosign verify \ - --certificate-identity "https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@$RELEASE_REVISION" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$IMAGE@$PLATFORM_DIGEST" - gh attestation verify "oci://$IMAGE@$PLATFORM_DIGEST" \ - --repo "$REPOSITORY" \ - --bundle-from-oci \ - --predicate-type https://spdx.dev/Document/v2.3 \ - --signer-workflow meigma/release/.github/workflows/publish-oci-image.yml \ - --signer-digest "$RELEASE_REVISION" \ - --source-ref "refs/tags/$TAG" \ - --deny-self-hosted-runners -done -``` - -Finally, run both published platforms: - -```bash -docker run --rm --platform linux/amd64 "$IMAGE@$DIGEST" --version -docker run --rm --platform linux/arm64 "$IMAGE@$DIGEST" --version -``` - -Both commands must report the release version and commit. Running a non-native platform requires binfmt/QEMU support. - -## Recovery - -A failed publisher leaves the GitHub Release draft unpublished because `github-release` depends on `oci-publish` and requires its digest-pinned image output. The registry effect depends on the phase that fails: - -- A failed prepare can leave untagged, digest-addressed blobs or manifests. It creates or moves no tag, so tag-based consumers see no release change. -- A failed attestation can leave a signed digest and partial trust metadata. It creates or moves no tag. -- A failed finalize can leave only a prefix of the planned tags on the candidate digest. The digest already has its required signatures and attestations, but the workflow remains failed. - -When repository content is unchanged, rerun only the failed jobs: - -```bash -gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed -``` - -This preserves the successful builder job and reuses its authoritative OCI artifact. The publisher runs prepare again, records current observations, completes the three attestation steps, and gives finalize a result from that run. Finalize then reads fresh registry state, accepts tags that already resolve to the candidate digest, and applies the remaining eligible tags. A retry after a partial success may add duplicate valid signatures or attestations. - -Never hand-replay a saved prepare result. It records registry observations from an earlier point in time and is not a durable receipt. Rerun the publisher so prepare and finalize are paired around the attestation steps and finalize can detect drift. - -If finalize reports drift, inspect the named tag and the current registry state before another run. Drift means the registry changed in a way that does not match either prepare's observation or this publication's candidate digest. - -If source, workflow configuration, or tool pins must change, follow the unpublished-tag recovery procedure in [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). Never move a tag after its GitHub Release is public. Never delete and recreate a public exact-version image tag to substitute different content. Publish a corrective release instead. diff --git a/docs/how-to/install-release-cli-from-package-repositories.md b/docs/how-to/install-release-cli-from-package-repositories.md deleted file mode 100644 index 7051c98..0000000 --- a/docs/how-to/install-release-cli-from-package-repositories.md +++ /dev/null @@ -1,168 +0,0 @@ -# Install release-cli from native package repositories - -Use this guide to install and update `release-cli` from the signed APT, DNF, or APK repository at `https://pkgs.meigma.dev`. - -The repository publishes amd64 and arm64 packages. Each client verifies both repository metadata and the producer package before installation. Do not disable either check when recovering a failed install. - -## Prerequisites - -You need: - -- root access on the target host; -- a working system clock and HTTPS certificate store; -- Debian or Ubuntu with APT, a DNF-based Linux distribution, or Alpine Linux. - -## Install with APT - -Install the HTTPS prerequisites from the distribution repositories: - -```sh -sudo apt-get update -sudo apt-get install -y ca-certificates curl -``` - -Install the repository key in a dedicated keyring file: - -```sh -sudo install -d -m 0755 /etc/apt/keyrings -curl --fail --silent --show-error --location \ - https://pkgs.meigma.dev/keys/apt-repository-001.asc \ - | sudo tee /etc/apt/keyrings/meigma-packages.asc >/dev/null -sudo chmod 0644 /etc/apt/keyrings/meigma-packages.asc -``` - -Add the signed repository and install the command: - -```sh -printf '%s\n' \ - 'deb [signed-by=/etc/apt/keyrings/meigma-packages.asc] https://pkgs.meigma.dev/apt stable main' \ - | sudo tee /etc/apt/sources.list.d/meigma-packages.list >/dev/null -sudo apt-get update -sudo apt-get install -y release-cli -``` - -Confirm the installed package: - -```sh -dpkg-query --show --showformat='${Package} ${Version} ${Architecture}\n' release-cli -``` - -## Install with DNF - -Create a repository definition that checks the aggregate repository signature and the producer RPM signature: - -```sh -sudo tee /etc/yum.repos.d/meigma-packages.repo >/dev/null <<'EOF' -[meigma-packages] -name=Meigma packages -baseurl=https://pkgs.meigma.dev/rpm/stable/$basearch -enabled=1 -gpgcheck=1 -repo_gpgcheck=1 -gpgkey=https://pkgs.meigma.dev/keys/rpm-repository-001.asc https://pkgs.meigma.dev/keys/release-rpm-001.asc -EOF -``` - -Install and inspect the package: - -```sh -sudo dnf install -y release-cli -rpm -q --qf '%{NAME} %{VERSION}-%{RELEASE} %{ARCH}\n' release-cli -``` - -Keep both `gpgcheck=1` and `repo_gpgcheck=1`. The first checks the producer-signed RPM; the second checks aggregate repository metadata. - -## Install with APK - -APK identifies signing keys by filename. Keep the published filenames unchanged when you install them: - -```sh -sudo wget -q \ - https://pkgs.meigma.dev/keys/apk-index-001.rsa.pub \ - -O /etc/apk/keys/apk-index-001.rsa.pub -sudo wget -q \ - https://pkgs.meigma.dev/keys/meigma-release-001.rsa.pub \ - -O /etc/apk/keys/meigma-release-001.rsa.pub -``` - -Add the repository, refresh its signed index, and install the command: - -```sh -printf '%s\n' 'https://pkgs.meigma.dev/apk/stable/main' \ - | sudo tee -a /etc/apk/repositories >/dev/null -sudo apk update -sudo apk add release-cli -apk info --installed release-cli -``` - -The aggregate key verifies `APKINDEX.tar.gz`. The producer key verifies the package. Both keys are required. - -## Update release-cli - -Use the package manager's normal update path: - -```sh -sudo apt-get update && sudo apt-get install --only-upgrade release-cli -``` - -```sh -sudo dnf upgrade release-cli -``` - -```sh -sudo apk update && sudo apk upgrade release-cli -``` - -The repository retains published versions. A normal install selects the newest version accepted by the client unless the host has a package pin or version lock. - -## Recover a failed install - -### TLS verification fails - -Check the system clock and update the host's CA certificate package from its distribution repository. Do not replace `https://pkgs.meigma.dev` with HTTP and do not disable certificate verification. - -### A repository or package signature is rejected - -Stop before installing the package. Do not use APT `trusted=yes`, DNF `gpgcheck=0`, APK `--allow-untrusted`, or a similar bypass. - -Confirm that the configured key URLs and filenames match the reviewed package policy. The current public names are: - -- `apt-repository-001.asc` for APT metadata; -- `rpm-repository-001.asc` and `release-rpm-001.asc` for DNF metadata and packages; -- `apk-index-001.rsa.pub` and `meigma-release-001.rsa.pub` for APK indexes and packages. - -A key replacement uses a new reviewed public-key object; immutable key objects are not overwritten. Replace keys manually: - -1. Obtain the new key URL, expected fingerprint or digest, and activation notice through the trusted release channel. -2. Download the new key to a temporary file over HTTPS. -3. Compare its fingerprint or digest with the independently reviewed value. -4. Install the new key alongside the old key. For APK, preserve the announced basename exactly. -5. Update the APT `signed-by` path, DNF `gpgkey` list, or APK key set to include the new key. -6. Refresh repository metadata and complete one verified installation. -7. Remove the retired key only after the refreshed metadata and package both verify. - -If no reviewed replacement exists, leave the old configuration in place and report the failure. A repeated signature error can indicate stale metadata, an incomplete key rollout, or tampering; bypassing verification hides the distinction. - -### The requested version is unavailable - -Refresh metadata, then inspect the versions visible to the client: - -```sh -apt-cache policy release-cli -``` - -```sh -dnf --showduplicates list release-cli -``` - -```sh -apk policy release-cli -``` - -If the release is visible on GitHub but absent from the package repository, wait for the central publication workflow or contact its operator. Do not install an unverified package copied from a failed publication run. - -### An update was interrupted - -Run the metadata refresh and install command again. Publication is convergent: immutable package objects are never overwritten, and repository roots become visible only after their referenced objects are uploaded. A replay either completes the same state or fails without accepting conflicting immutable content. - -For repository operation and replay procedures, see [Set up the shared package repository](set-up-package-repository.md). The [package repository contract](../reference/package-repository-contract.md) defines the public paths, trust checks, cache behavior, and publication states. diff --git a/docs/how-to/install-release-cli-with-mise.md b/docs/how-to/install-release-cli-with-mise.md deleted file mode 100644 index b2ece95..0000000 --- a/docs/how-to/install-release-cli-with-mise.md +++ /dev/null @@ -1,87 +0,0 @@ -# Install `release-cli` with mise - -Use mise's built-in GitHub backend to install a tagged `release-cli` release. The -current release does not require a mise plugin or a custom installer. - -This guide is for direct command-line use. Repositories that call the reusable -Meigma release workflows do not install or version `release-cli` separately. -The pinned workflow revision selects and verifies the matching CLI release. - -## Install a project version - -From the project directory, install the current release: - -```bash -mise use github:meigma/release@0.1.3 -``` - -Mise writes this tool entry to the project's `mise.toml`: - -```toml -[tools] -"github:meigma/release" = "0.1.3" -``` - -Verify the installed command: - -```bash -mise exec -- release-cli version --json -``` - -The current release reports: - -```json -{ - "schema": "release.dev/result/v1", - "command": "version", - "ok": true, - "result": { - "version": "0.1.3", - "commit": "0fc99489d31d400bc3f69d6636d60e7d3f3d0251", - "protocol": 1 - } -} -``` - -Mise selects the release archive for the host operating system and architecture. -The `v0.1.3` release contains archives for Darwin, Linux, and Windows on `amd64` -and `arm64`. During a verified installation, mise reports the selected archive, -its checksum check, and GitHub artifact attestation verification. - -## Run without changing project configuration - -Use `mise x` for a temporary invocation: - -```bash -mise x github:meigma/release@0.1.3 -- release-cli version --json -``` - -## Install for your user account - -Add `-g` to write the tool entry to the global mise configuration: - -```bash -mise use -g github:meigma/release@0.1.3 -mise exec -- release-cli version --json -``` - -Pin an explicit version in project and automation configuration. Update the -version deliberately after verifying the target GitHub Release. - -## Use a local shorthand - -`release-cli` is not registered in the mise registry. The full backend name is -therefore required by default; `mise use release-cli@0.1.3` does not resolve. - -To define a local shorthand, add an alias and tool entry: - -```toml -[tool_alias] -release-cli = "github:meigma/release" - -[tools] -release-cli = "0.1.3" -``` - -The alias affects only the mise configuration that defines it. It does not -publish `release-cli` to the shared mise registry. diff --git a/docs/how-to/install-release-cli-with-nix.md b/docs/how-to/install-release-cli-with-nix.md deleted file mode 100644 index 7032bd1..0000000 --- a/docs/how-to/install-release-cli-with-nix.md +++ /dev/null @@ -1,118 +0,0 @@ -# Install `release-cli` with Nix - -Use the repository flake to build and run a tagged `release-cli` revision. The -flake supports Darwin and Linux on `arm64` and `amd64`. - -This guide is for direct command-line and development-shell use. Repositories -that call the reusable Meigma release workflows do not install or version -`release-cli` separately. Their pinned workflow revision selects the matching -CLI release. - -## Run without installing - -Run the current release directly from GitHub: - -```bash -nix run github:meigma/release/v0.1.3#release-cli -- version --json -``` - -The current release reports: - -```json -{ - "schema": "release.dev/result/v1", - "command": "version", - "ok": true, - "result": { - "version": "0.1.3", - "commit": "0fc99489d31d400bc3f69d6636d60e7d3f3d0251", - "protocol": 1 - } -} -``` - -The first invocation builds `release-cli` when the result is not already in a -configured Nix cache. - -## Install into your Nix profile - -Install the tagged package for your user account: - -```bash -nix profile add github:meigma/release/v0.1.3#release-cli -release-cli version --json -``` - -Pin an explicit tag in interactive installation commands. For project and CI -use, add the flake as an input so `flake.lock` records its exact commit and -content hash. - -## Add `release-cli` to a project flake - -Add the release flake as an input and make it follow the project's `nixpkgs` -input: - -```nix -{ - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; - release = { - url = "github:meigma/release/v0.1.3"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = - { nixpkgs, release, ... }: - let - system = "aarch64-darwin"; - pkgs = import nixpkgs { inherit system; }; - in - { - devShells.${system}.default = pkgs.mkShellNoCC { - packages = [ release.packages.${system}.release-cli ]; - }; - }; -} -``` - -Replace `aarch64-darwin` with the project's host system. Supported values are: - -- `aarch64-darwin` -- `aarch64-linux` -- `x86_64-darwin` -- `x86_64-linux` - -Lock and verify the input: - -```bash -nix flake lock -nix develop --command release-cli version --json -``` - -The [Nix consumer example](../../examples/nix-release-cli/) exposes the package, -app, and development shell on all four systems. - -## Update the pinned release - -Change `inputs.release.url` to the new tag, then update only that input: - -```bash -nix flake update release -nix develop --command release-cli version --json -``` - -Commit the changed `flake.lock` with the version update. - -## Understand the build and trust boundary - -The flake builds `release-cli` from the exact Git revision in the locked input. -It uses the locked Nixpkgs revision, a fixed Go 1.26.6 source hash, -and a fixed Go module dependency hash. It embeds the manifest version and source -revision in the resulting binary. - -This path does not install the prebuilt GitHub Release archive and does not run -GitHub artifact-attestation verification. Nix instead verifies every locked -flake input and fixed-output dependency. Use the [mise installation -path](install-release-cli-with-mise.md) when you need the released archive, -checksum, and GitHub artifact-attestation checks. diff --git a/docs/how-to/install-release-cli.md b/docs/how-to/install-release-cli.md new file mode 100644 index 0000000..954b39d --- /dev/null +++ b/docs/how-to/install-release-cli.md @@ -0,0 +1,301 @@ +# Install `release-cli` + +Use this guide to install `release-cli` for direct command-line use. Repositories +that call the reusable workflows do not install or pin the CLI separately; the +one workflow revision selects it as part of the release unit. + +The `meigma/release` and `pkgs.meigma.dev` URLs on this page install +`release-cli` itself. Adopting organizations must use their own repositories and +origins for their applications. + +## Select a released version + +For mise, Nix, or a direct archive, select one published stable release and +resolve its tag to a full commit: + +```bash +export RELEASE_TAG="$(gh api repos/meigma/release/releases/latest --jq .tag_name)" +export RELEASE_VERSION="${RELEASE_TAG#v}" +export RELEASE_REVISION="$(gh api "repos/meigma/release/commits/$RELEASE_TAG" --jq .sha)" +[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] +[[ "$RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] +printf 'Installing release-cli %s from %s\n' "$RELEASE_VERSION" "$RELEASE_REVISION" +``` + +For automation, set `RELEASE_TAG`, `RELEASE_VERSION`, and `RELEASE_REVISION` to +a reviewed release rather than resolving the latest release on every run. + +## Install with mise + +Use mise's built-in GitHub backend: + +```bash +mise use "github:meigma/release@$RELEASE_VERSION" +mise exec -- release-cli version --json +``` + +Mise writes an explicit project version: + +```toml +[tools] +"github:meigma/release" = "" +``` + +For a temporary invocation: + +```bash +mise x "github:meigma/release@$RELEASE_VERSION" -- release-cli version --json +``` + +For a user-level installation: + +```bash +mise use -g "github:meigma/release@$RELEASE_VERSION" +mise exec -- release-cli version --json +``` + +`release-cli` is not registered under a short name in the shared mise registry. +To use one locally, add: + +```toml +[tool_alias] +release-cli = "github:meigma/release" + +[tools] +release-cli = "" +``` + +The released archives cover Darwin, Linux, and Windows on `amd64` and `arm64`. +Mise's verified GitHub backend selects the host archive and checks its release +checksum and GitHub artifact attestation. + +To update, review a newer release and run: + +```bash +mise use "github:meigma/release@$RELEASE_VERSION" +mise install --locked +mise exec -- release-cli version --json +``` + +Commit the changed `mise.toml` and `mise.lock` when the tool is project-scoped. + +## Install with Nix + +The repository flake builds `release-cli` from source for Darwin and Linux on +`aarch64` and `x86_64`. + +Run the selected immutable revision without installing: + +```bash +nix run "github:meigma/release/$RELEASE_REVISION#release-cli" -- version --json +``` + +Install it into the current profile: + +```bash +nix profile add "github:meigma/release/$RELEASE_REVISION#release-cli" +release-cli version --json +``` + +For a project flake, add a tagged input and make it follow the project's +`nixpkgs` input: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + release = { + url = "github:meigma/release/vMAJOR.MINOR.PATCH"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { nixpkgs, release, ... }: + let + system = "aarch64-darwin"; + pkgs = import nixpkgs { inherit system; }; + in { + devShells.${system}.default = pkgs.mkShellNoCC { + packages = [ release.packages.${system}.release-cli ]; + }; + }; +} +``` + +Replace the tag and system. Supported systems are `aarch64-darwin`, +`aarch64-linux`, `x86_64-darwin`, and `x86_64-linux`. + +Lock and run the input: + +```bash +nix flake lock +nix develop --command release-cli version --json +``` + +To update, change the input tag and update only that input: + +```bash +nix flake update release +nix develop --command release-cli version --json +``` + +Commit `flake.lock`. This path builds from the locked source and fixed-output +dependencies. It does not install or verify the prebuilt GitHub Release archive. + +## Install from the APT repository + +The public Meigma repository distributes `release-cli` on `amd64` and `arm64`. +Install the HTTPS prerequisites and reviewed aggregate key: + +```sh +sudo apt-get update +sudo apt-get install -y ca-certificates curl +sudo install -d -m 0755 /etc/apt/keyrings +curl --fail --silent --show-error --location \ + https://pkgs.meigma.dev/keys/apt-repository-001.asc \ + | sudo tee /etc/apt/keyrings/meigma-packages.asc >/dev/null +sudo chmod 0644 /etc/apt/keyrings/meigma-packages.asc +printf '%s\n' \ + 'deb [signed-by=/etc/apt/keyrings/meigma-packages.asc] https://pkgs.meigma.dev/apt stable main' \ + | sudo tee /etc/apt/sources.list.d/meigma-packages.list >/dev/null +sudo apt-get update +sudo apt-get install -y release-cli +release-cli version --json +``` + +APT verifies the aggregate signed repository metadata. Update through the same +repository: + +```sh +sudo apt-get update +sudo apt-get install --only-upgrade release-cli +``` + +## Install from the DNF repository + +Configure both the aggregate RPM metadata key and the producer package key: + +```sh +sudo tee /etc/yum.repos.d/meigma-packages.repo >/dev/null <<'EOF' +[meigma-packages] +name=Meigma packages +baseurl=https://pkgs.meigma.dev/rpm/stable/$basearch +enabled=1 +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=https://pkgs.meigma.dev/keys/rpm-repository-001.asc https://pkgs.meigma.dev/keys/release-rpm-001.asc +EOF +sudo dnf install -y release-cli +release-cli version --json +``` + +Keep both `gpgcheck=1` and `repo_gpgcheck=1`. Update with: + +```sh +sudo dnf upgrade release-cli +``` + +## Install from the APK repository + +APK identifies signing keys by filename. Preserve both reviewed basenames: + +```sh +sudo wget -q \ + https://pkgs.meigma.dev/keys/apk-index-001.rsa.pub \ + -O /etc/apk/keys/apk-index-001.rsa.pub +sudo wget -q \ + https://pkgs.meigma.dev/keys/meigma-release-001.rsa.pub \ + -O /etc/apk/keys/meigma-release-001.rsa.pub +printf '%s\n' 'https://pkgs.meigma.dev/apk/stable/main' \ + | sudo tee -a /etc/apk/repositories >/dev/null +sudo apk update +sudo apk add release-cli +release-cli version --json +``` + +The aggregate key verifies `APKINDEX.tar.gz`; the producer key verifies the APK. +Update with: + +```sh +sudo apk update +sudo apk upgrade release-cli +``` + +## Install a verified GitHub archive + +Use this path when mise, Nix, and the native repositories are unavailable. The +following Bash procedure supports Darwin and Linux on `amd64` and `arm64`. + +Derive the released archive name: + +```bash +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) printf 'Unsupported operating system\n' >&2; exit 1 ;; +esac +case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) printf 'Unsupported architecture\n' >&2; exit 1 ;; +esac +export ARCHIVE="release-cli_${RELEASE_VERSION}_${os}_${arch}.tar.gz" +``` + +Download and verify the selected release: + +```bash +export INSTALL_DIR="$(mktemp -d)" +gh release download "$RELEASE_TAG" \ + --repo meigma/release \ + --dir "$INSTALL_DIR" \ + --pattern "$ARCHIVE" \ + --pattern checksums.txt \ + --pattern checksums.txt.sigstore.json +cd "$INSTALL_DIR" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum --check --ignore-missing checksums.txt +else + shasum -a 256 --check checksums.txt --ignore-missing +fi +cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@refs/tags/$RELEASE_TAG" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.txt +gh attestation verify "$ARCHIVE" \ + --repo meigma/release \ + --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ + --signer-digest "$RELEASE_REVISION" \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --deny-self-hosted-runners +tar -xzf "$ARCHIVE" +sudo install -m 0755 release-cli /usr/local/bin/release-cli +/usr/local/bin/release-cli version --json +``` + +Do not extract or install the binary when a checksum, Cosign identity, issuer, +or GitHub attestation check fails. Repeat the complete procedure with a reviewed +new tag to update. + +On Windows, use the corresponding +`release-cli__windows_.zip`. Verify its SHA-256 entry from +`checksums.txt`, run the same `gh attestation verify` signer and source +constraints, expand the ZIP, and place `release-cli.exe` in an administrator- +controlled directory on `PATH`. + +## Choose the trust path + +| Method | Installed content | Verification boundary | +| --- | --- | --- | +| mise | Prebuilt release archive | Release checksum and GitHub artifact attestation through mise's GitHub backend. | +| Nix | Source build | Locked Git source, Nixpkgs input, Go source, and fixed dependency hashes. | +| APT | Native DEB | HTTPS plus signed aggregate APT metadata. | +| DNF | Native RPM | HTTPS, signed aggregate RPM metadata, and producer RPM signature. | +| APK | Native APK | HTTPS, aggregate APK index signature, and producer APK signature. | +| Direct archive | Prebuilt release archive | Local checksum, exact Cosign workflow identity, and GitHub artifact attestation. | + +Do not recover an installation by using HTTP, APT `trusted=yes`, DNF +`gpgcheck=0`, APK `--allow-untrusted`, or skipped checksum and attestation +checks. Correct the system clock, CA store, key files, or selected release +instead. diff --git a/docs/how-to/operate-a-native-package-repository.md b/docs/how-to/operate-a-native-package-repository.md new file mode 100644 index 0000000..690679c --- /dev/null +++ b/docs/how-to/operate-a-native-package-repository.md @@ -0,0 +1,372 @@ +# Operate a native package repository + +Use this guide to operate one adopter-owned APT, DNF, and APK repository backed +by Cloudflare R2. Producers send only their repository and stable release tag to +a central receiver. They never receive R2 credentials or aggregate repository +signing keys. + +The public repository is not `pkgs.meigma.dev`. Choose and operate your own +Cloudflare account, bucket, custom domain, GitHub repository, and keys. + +## Prepare the central repository + +Create one central GitHub repository, such as `acme/packages`. Keep these files +under review: + +```text +.config/package-repository.yaml +.config/keys/ +.github/workflows/publish-package-release.yml +``` + +Install the adopter-owned release App on the central repository. It needs +`contents: write` so producer workflows can create `repository_dispatch` +events. Keep the App client ID and private key available to producer +repositories, not to the central publication environment. + +All package-repository writes must pass through this central workflow. The +reusable receiver serializes writers with one non-cancelling concurrency group; +the CLI and R2 do not provide a distributed repository lock. + +## Configure Cloudflare R2 + +In the adopter's Cloudflare account: + +1. Create one R2 bucket for the package repository. +2. Attach a public custom domain to the bucket root. +3. Require HTTPS at that domain. +4. Create an R2 S3 token scoped to this bucket with list, read, and write object + access. +5. Record the Cloudflare account ID, bucket name, S3 access key ID, and S3 + secret access key. + +The policy `origin` must be an absolute HTTPS root such as +`https://packages.example.com`. It cannot contain a path prefix, query, +fragment, credentials, or a trailing alternate origin. Clients use the custom +domain. The publisher uses the account-specific S3-compatible endpoint derived +from the Cloudflare account ID. + +Delete permission is not required. Publication never deletes or prunes an +object. + +Do not add a cache rule that overrides the publisher's `Cache-Control` values. +The publisher assigns: + +- `public, max-age=31536000, immutable` to packages, public keys, and APT + by-hash objects; and +- `no-store` to indexes, signatures, and other replaceable metadata. + +Confirm that the custom domain and any Cloudflare cache rules preserve these +headers. Caching replaceable roots can leave clients on a repository view that +the operator has already replaced. + +Add these repository variables to `acme/packages`: + +| Variable | Value | +| --- | --- | +| `CLOUDFLARE_ACCOUNT_ID` | Account that owns the bucket. | +| `PACKAGE_REPOSITORY_R2_BUCKET` | Existing R2 bucket name. | + +## Create the signing domains + +The central repository needs: + +- one passphrase-protected OpenPGP private key for aggregate APT and RPM + metadata; +- the corresponding OpenPGP public key; +- one RSA private key for aggregate APK indexes; and +- the corresponding RSA public key. + +Each producer also needs its own: + +- OpenPGP key pair for RPM package signatures; and +- RSA key pair for APK package signatures. + +Keep producer private keys in that producer's Actions secrets. Keep aggregate +private keys only in the central protected environment. Commit only public keys +to `.config/keys/`. + +The aggregate APT and RPM entries may refer to the same OpenPGP public-key file. +Every published key filename must be unique and stable. A key replacement uses +a new reviewed object name; immutable public-key objects are not overwritten. + +## Write the producer policy + +Create `.config/package-repository.yaml`. The following is a template; replace +the domain and `REPLACE_WITH_RELEASE_COMMIT_SHA` before use: + +```yaml +channel: stable +origin: https://packages.example.com +keys: + apt: + source: keys/repository.asc + published: apt-repository-001.asc + rpm: + source: keys/repository.asc + published: rpm-repository-001.asc + apk: + source: keys/repository-apk.rsa.pub + published: apk-index-001.rsa.pub +producers: + - repository: acme/widget + packages: + - widget + checksum_identity: https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + attestation_signer: meigma/release/.github/workflows/publish-github-release.yml + rpm_key: + source: keys/widget-rpm.asc + published: widget-rpm-001.asc + apk_key: + source: keys/widget-apk.rsa.pub + published: widget-apk-001.rsa.pub +``` + +The signer fields are explicit shared-workflow identities: + +- `checksum_identity` must be exactly + `https://github.com///.github/workflows/@<40-lowercase-sha>`. + Branches, tags, abbreviated SHAs, non-GitHub hosts, queries, and fragments are + rejected. +- `attestation_signer` must be exactly + `//.github/workflows/`, without a URL or ref. + +In both fields, the workflow filename must end in `.yml` or `.yaml`. + +For a producer that calls `meigma/release`, both fields name the reusable +workflow repository, not `acme/widget`. The checksum identity pins the release +unit. GitHub attestation verification independently binds the package to the +producer repository, `refs/tags/`, the resolved producer commit, and the +configured shared signer workflow. + +Replace the revision placeholder with the same full commit used in that +producer's release caller. Update the producer caller and this reviewed policy +atomically before the next release. Do not derive trust from the producer tag +or assume that signer workflows belong to the producer repository. + +A package name belongs to one producer. Each requested release must contain one +DEB, RPM, and APK for every allowlisted package on both `amd64` and `arm64`. +Add the referenced public-key files beneath `.config/keys/`, then merge the +policy and keys through the central repository's review process. + +## Protect the production environment + +Create a GitHub environment named `packages-production` in `acme/packages`. +Add required reviewers and restrict who can change its configuration. + +Add these environment secrets: + +| Secret | Content | +| --- | --- | +| `R2_ACCESS_KEY_ID` | Bucket-scoped R2 S3 access key ID. | +| `R2_SECRET_ACCESS_KEY` | Bucket-scoped R2 S3 secret access key. | +| `PACKAGE_REPOSITORY_GPG_PRIVATE_KEY` | Base64-encoded armored aggregate OpenPGP private key. | +| `PACKAGE_REPOSITORY_GPG_PASSPHRASE` | Aggregate OpenPGP key passphrase. | +| `PACKAGE_REPOSITORY_APK_PRIVATE_KEY` | Base64-encoded aggregate APK RSA private key. | + +Encode each private key as one line: + +```bash +base64 < repository-private-key.asc | tr -d '\n' +base64 < repository-apk.rsa | tr -d '\n' +``` + +The setup action decodes these values into owner-only files on the ephemeral +runner, imports exactly one OpenPGP primary secret key, and removes the decoded +OpenPGP import file before publication. + +## Add the receiver workflow + +Create `.github/workflows/publish-package-release.yml` in the central +repository. Replace the revision placeholder with the reviewed full SHA used by +its producer policies: + +```yaml +name: Publish package release + +on: + repository_dispatch: + types: + - package-release + +permissions: + attestations: read + contents: read + +jobs: + publish: + uses: meigma/release/.github/workflows/publish-package-repository.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + repository: ${{ github.event.client_payload.repository }} + tag: ${{ github.event.client_payload.tag }} + cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + r2-bucket: ${{ vars.PACKAGE_REPOSITORY_R2_BUCKET }} +``` + +Keep this file on the central repository's default branch. GitHub processes a +`repository_dispatch` only when a matching workflow exists there. + +The called workflow selects `packages-production`, checks out the caller's +reviewed policy and public keys, builds `release-cli` from the exact reusable +workflow source, and invokes one publication command. It requests only +`contents: read` and `attestations: read`; R2 and aggregate signing authority +come from the protected environment. + +## Enable native signing in each producer + +The producer's `.goreleaser.yaml` must keep nFPM ID `release` and these signature +key expressions: + +```yaml +nfpms: + - id: release + rpm: + signature: + key_file: "{{ .Env.RELEASE_RPM_SIGNING_KEY_FILE }}" + apk: + signature: + key_file: "{{ .Env.RELEASE_APK_SIGNING_KEY_FILE }}" + key_name: widget-001 +``` + +Add the producer private keys and passphrases as Actions secrets, then enable +and map them in the `release-assets` call: + +```yaml + release-assets: + permissions: + attestations: read + contents: read + id-token: write + uses: meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + sign-native-packages: true + secrets: + rpm-signing-key: ${{ secrets.RPM_SIGNING_KEY }} + rpm-signing-passphrase: ${{ secrets.RPM_SIGNING_PASSPHRASE }} + apk-signing-key: ${{ secrets.APK_SIGNING_KEY }} + apk-signing-passphrase: ${{ secrets.APK_SIGNING_PASSPHRASE }} +``` + +The workflow validates all four secrets, materializes owner-only temporary key +files, and removes them after staging. GoReleaser signs RPM and APK bytes before +`checksums.txt` is generated. A package repository rejects unsigned or +wrong-key RPM and APK packages even when their release checksums are valid. + +## Onboard the producer dispatch + +Install the adopter-owned App on both `acme/widget` and `acme/packages`. Make the +App client-ID variable and private-key secret available to the producer. + +Customize the maintained request job and leave it disabled: + +```yaml + package-repository: + name: Request package repository publication + needs: github-release + permissions: {} + uses: meigma/release/.github/workflows/request-package-repository.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + package-repository-owner: acme + package-repository-name: packages + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-package-repository: false + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} +``` + +The request sends only `acme/widget` and its exact tag. It does not send +artifacts, R2 credentials, or aggregate keys. The central publisher downloads +the public GitHub Release and applies the reviewed policy. + +Enable `publish-package-repository: true` only after all of these conditions +hold: + +- `publish-release: true` is enabled for the same tag-triggered run; +- the producer release workflow signs RPM and APK packages; +- the policy contains the producer's exact checksum and attestation signers; +- both producer public keys are committed; +- the central receiver workflow is on the default branch; +- the protected environment and R2 variables are configured; and +- the App is installed on the central repository. + +The job must remain after `github-release`; the receiver rejects a missing, +draft, prerelease, or mismatched release. + +## Accept the first publication + +Publish one stable producer release. Approve the central protected-environment +deployment and watch the central workflow: + +```bash +gh run list --repo acme/packages --workflow publish-package-release.yml --limit 5 +gh run view --repo acme/packages --log +``` + +The CLI performs two installation passes with pinned Debian, Fedora, and Alpine +containers: + +1. before upload, it mounts the generated tree and reviewed keys read-only, + disables networking, and installs the exact requested package version with + APT, DNF, and APK; and +2. after upload, it installs the same version from the public HTTPS origin. + +DNF checks both the aggregate RPM metadata signature and the producer RPM +signature. APK checks both the aggregate index signature and the producer APK +signature. A successful receiver workflow proves that all six local and public +client checks passed. + +Check the public roots and one reviewed key at the configured origin: + +```bash +export PACKAGE_ORIGIN=https://packages.example.com +curl --fail --silent --show-error \ + "$PACKAGE_ORIGIN/apt/dists/stable/InRelease" >/dev/null +curl --fail --silent --show-error \ + "$PACKAGE_ORIGIN/rpm/stable/x86_64/repodata/repomd.xml" >/dev/null +curl --fail --silent --show-error \ + "$PACKAGE_ORIGIN/apk/stable/main/x86_64/APKINDEX.tar.gz" >/dev/null +curl --fail --silent --show-error \ + "$PACKAGE_ORIGIN/keys/apt-repository-001.asc" >/dev/null +``` + +Do not configure clients until the receiver is green and these public objects +are available over HTTPS. + +## Replay and recover + +Authenticate `gh` as an authorized operator whose token has `Contents: write` +access to the central repository. Then replay an exact published producer +release by sending the same dispatch again: + +```bash +export PRODUCER_REPOSITORY=acme/widget +export PRODUCER_TAG=v1.2.3 +gh api --method POST repos/acme/packages/dispatches --input - </dev/null +test -s "$INSPECT_DIR/oci-image/image-digest.txt" +test -s "$INSPECT_DIR/oci-image/layout/index.json" +jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture)"' \ + "$INSPECT_DIR/oci-image/layout/index.json" | sort +``` + +On macOS, use `shasum -a 256 --check checksums.txt`. Review the job logs, +generated Homebrew and Scoop controls, SBOMs, signatures, and expected +destination names. Do not publish the draft through the GitHub UI. + +## Resume the same unpublished candidate + +Change `publish-image` and `publish-release` to `true` in one reviewed commit. +Enable Homebrew, Scoop, or native package publication only if each destination's +external setup is complete. + +A rerun of the first Actions run still uses the tagged caller with publishers +disabled. To use the enabling commit, move the same unpublished rehearsal tag: + +```bash +git fetch origin "$DEFAULT_BRANCH" --tags +export RECOVERY_SHA="$(git rev-parse "origin/$DEFAULT_BRANCH")" +git tag --force "$TAG" "$RECOVERY_SHA" +git push --force origin "refs/tags/$TAG" +``` + +Use this operation only for a controlled unpublished rehearsal. Select and +watch the new run by the exact tag and recovery commit: + +```bash +export RESUME_RUN_COUNT=0 +until test "$RESUME_RUN_COUNT" -gt 0; do + export RESUME_RUN_COUNT="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$RECOVERY_SHA" \ + --event push \ + --limit 100 \ + --json databaseId \ + --jq 'length')" + test "$RESUME_RUN_COUNT" -gt 0 || sleep 2 +done +test "$RESUME_RUN_COUNT" -eq 1 +export RESUME_RUN_ID="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$RECOVERY_SHA" \ + --event push \ + --limit 100 \ + --json databaseId \ + --jq '.[0].databaseId')" +gh run watch "$RESUME_RUN_ID" --repo "$REPOSITORY" --compact --exit-status +``` + +Confirm that the same release ID became public: + +```bash +test "$(gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json databaseId \ + --jq .databaseId)" = "$RELEASE_ID" +gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json tagName,isDraft,isPrerelease,publishedAt,url +``` + +Verify the public release and digest-pinned image with the commands in +[Release your first Go application](../tutorials/release-your-first-go-application.md#verify-the-release-and-image). + +## Choose the correct retry + +Use the smallest retry that preserves the authoritative artifact and reviewed +content: + +| Condition | Action | +| --- | --- | +| Repository content is unchanged, the candidate remains a draft, and upstream artifacts are valid | `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed` | +| A transient failure requires the complete graph but no content change | `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY"` | +| An Actions artifact expired or its handoff is invalid | Rerun the complete graph to build a new artifact. | +| Source, workflow configuration, signer pin, or tool lock changes | Merge the correction and move the unpublished tag to that commit, or abandon the candidate. | +| The release may already be public | Inspect remote state before any rerun. | +| The release is public and incorrect | Preserve it and publish a corrective version. | + +Select a failed run by tag and commit, not by recency alone: + +```bash +git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force +export FAILED_SHA="$(git rev-list -n 1 "$TAG")" +export FAILED_RUN_ID="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$FAILED_SHA" \ + --event push \ + --status failure \ + --limit 100 \ + --json databaseId \ + --jq '.[0].databaseId')" +test -n "$FAILED_RUN_ID" +gh run view "$FAILED_RUN_ID" --repo "$REPOSITORY" --log-failed +``` + +## Diagnose publisher failures + +### Release Please or draft discovery + +If no matching draft exists: + +1. inspect the Release Please run that created the tag; +2. confirm the App installation, variable, secret, and protected-tag bypass; +3. query the paginated releases collection for the exact tag; and +4. confirm that Release Please created one tag and one draft. + +Do not create an unrelated draft to satisfy the publisher. If Release Please +cannot reconcile an incomplete unpublished candidate, remove it only through an +authorized incident process and cut a new candidate. + +### Build, signing, or staging + +`release-cli stage --profile go` invokes GoReleaser before validating the +bundle. Correct the source or `.goreleaser.yaml` when staging reports an invalid +checksum, missing canonical Linux binary, dynamic executable, escaped path, or +irregular file. + +When macOS signing is enabled, confirm all five Apple credentials and inspect +Quill's rejection or timeout. When native package signing is enabled, confirm +all four credentials, owner-only key files, nFPM ID `release`, and the fixed key +expressions. Do not disable signing to make a producer eligible for a native +repository whose policy requires signed RPM and APK packages. + +### Artifact handoff + +A publisher rejects an artifact from another run, an expired artifact, a +non-positive ID, or a GitHub digest mismatch. Do not substitute an artifact +from another run. Rerun the complete top-level workflow when a new authoritative +artifact is required. + +### Checksum or signer verification + +Require the one release-unit identity: + +```text +https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@ +``` + +Do not relax the issuer, change the identity to a branch or tag, or upload files +manually. Correct every reusable workflow reference and signer field together, +then build a new artifact from the correction commit. + +### Unexpected GitHub Release assets + +The GitHub publisher refuses an asset name outside the signed closed set and +never deletes it. Inspect the draft and determine its origin. + +- If the asset does not belong to the release contract, an authorized operator + must remove it from the draft before a retry. +- If it belongs in the release, change the producer so `checksums.txt` includes + it, merge the correction, and run from the correction commit. + +`--clobber` is used only for expected names after closed-set verification. It is +not an override for unexpected files. + +### Undraft or final-state uncertainty + +An undraft request has no rollback. If the request fails, a final read fails, or +the observed state is unexpected, `release-cli` reports an indeterminate state. +Inspect the release and all asset names, states, and digests before a rerun. + +A later publish-enabled invocation can report success without mutation only +when an already-public release exactly matches the expected closed set. A +public result from a draft-only invocation remains indeterminate even when the +assets match. The CLI never re-drafts a release. + +### OCI prepare, attestation, or finalize + +The phase determines the possible registry state: + +- failed preparation can leave untagged digest-addressed content; +- failed signing or attestation can leave partial trust metadata but no new + candidate tag; +- failed finalization can apply only a prefix of the planned tags; and +- the dependent GitHub Release remains a draft while OCI publication fails. + +Rerun failed jobs when the same authoritative image artifact remains valid. +Prepare reads registry state again, signing and attestations converge, and +finalize accepts candidate tags already applied before applying the remaining +eligible tags. Duplicate valid signatures or attestations can result. + +Never hand-replay a saved prepare envelope. It records an earlier registry +observation and is not a durable receipt. If finalize reports drift, inspect the +named tags and current digests before another attempt. + +### Homebrew or Scoop + +The caller schedules these publishers after the GitHub Release job. Enable them +only when that job also has `publish-release: true`; Homebrew and Scoop do not +independently check whether the release is public. They refuse a conflicting +destination branch, multiple matching pull requests, a same-or-newer +destination version with different content, or changes outside the one +generated file. + +Inspect the deterministic `release//v` branch and pull request. +Correct the producer control or destination conflict, then rerun the failed job. +Do not force-update the branch, bypass validation, enable auto-merge, or merge +an unreviewed control. + +### Native package repository + +The central receiver can fail on release closed-set verification, explicit +checksum or attestation signer policy, GitHub digests, package metadata, native +RPM/APK signatures, existing immutable R2 objects, aggregate signing, or local +and public client installation. + +Fix the failed prerequisite and replay the same `{repository, tag}` request. +Matching objects are skipped and replaceable metadata is regenerated. Do not +give a producer R2 credentials, bypass signature verification, or delete an +immutable object to make a replay succeed. + +## Upgrade the release unit atomically + +Before editing a producer, record the current and target full SHAs and review a +local diff between those exact commits. Include reusable workflows, the setup +action, example, two references, and any release-manifest stamp that changed. + +Apply the target contract in one pull request: + +1. replace every reusable `uses:` revision; +2. replace every `checksum-signing-workflow-ref` revision; +3. update explicit package policy `checksum_identity` values for the producer; +4. apply changed inputs, outputs, secrets, caller permissions, source contract, + asset contract, and tool declarations; +5. regenerate `mise.lock` only when tool declarations changed; and +6. complete external App, ruleset, credential, key, destination, or environment + prerequisites before triggering a candidate. + +Keep all publishers disabled during the upgrade rehearsal. Confirm that the +caller contains one target workflow ref and no old revision: + +```bash +! grep -F -q "$CURRENT_RELEASE_REVISION" .github/workflows/release.yml +refs="$(grep -Eo '@[0-9a-f]{40}' .github/workflows/release.yml | sort -u)" +test "$refs" = "@$NEW_RELEASE_REVISION" +grep -F 'publish-image: false' .github/workflows/release.yml +grep -F 'publish-release: false' .github/workflows/release.yml +``` + +Rehearse the target revision. The target [release system reference](../reference/release-system.md) +overrides old asset counts, identities, and interfaces. Enable publishers only +after the target draft, checksum identity, GitHub signer digest, and OCI +artifact pass review. + +## Roll back before publication + +If no candidate tag exists, reverse the complete repository change in one pull +request and restore every external prerequisite in an order that leaves the old +workflow operable. + +If the target revision populated an unpublished draft: + +1. restore all workflow refs, signer identities, permissions, interfaces, + source configuration, and tool pins together; +2. restore external App, ruleset, key, credential, and environment state; +3. keep publishers disabled; +4. move the unpublished tag to the rollback commit; and +5. verify the restored draft contract before publication. + +A previous contract can classify names introduced by the target revision as +unexpected. After confirming their origin, an authorized operator may remove +those names from the draft. Do not delete and recreate the draft. + +## Correct after publication + +Never move the tag, replace a public release asset, overwrite an exact OCI tag, +or rewrite immutable native package objects as routine recovery. Preserve the +published record, correct the complete release unit for future runs, rehearse a +new stable version, and publish that corrective release. Destructive removal +belongs only to a separately authorized release-incident process. diff --git a/docs/how-to/prepare-your-github-organization.md b/docs/how-to/prepare-your-github-organization.md new file mode 100644 index 0000000..f386f18 --- /dev/null +++ b/docs/how-to/prepare-your-github-organization.md @@ -0,0 +1,163 @@ +# Prepare your GitHub organization + +Use this guide to create the organization-owned GitHub identity and policy that +the release workflows require. Complete it before copying a release caller into +a producer repository. + +The App described here belongs to the adopting organization. No Meigma owner, +Meigma-owned App installation, or Meigma private key is required. + +## Create the release App + +Register a GitHub App under the adopting organization. Use an organization URL +for the required homepage field and disable webhooks unless the organization +uses them for another purpose. + +Grant these repository permissions: + +| Permission | Access | Used for | +| --- | --- | --- | +| Contents | Read and write | Release Please branches, tags, draft releases, release assets, destination branches, and `repository_dispatch`. | +| Issues | Read and write | Release Please issue and label operations. | +| Pull requests | Read and write | Release Please, Homebrew tap PRs, and Scoop bucket PRs. | + +Metadata read access is implicit. Do not add administration, Actions, packages, +or organization permissions for this release path. + +Record the App client ID. Generate one private key and store the downloaded PEM +in the organization's approved secret-management system. GitHub does not expose +the private key again. + +## Install the App on selected repositories + +Install the App on the adopting organization and select only repositories that +need release operations. Add repositories according to their role: + +- every producer repository; +- each adopter-owned Homebrew tap or Scoop bucket used by a producer; and +- the adopter-owned central package repository when producers dispatch native + package publication. + +Do not select **All repositories** merely to simplify onboarding. Add another +repository when its release configuration is reviewed. + +The publisher workflows request narrower installation tokens from this App: + +- GitHub Release publication requests `contents: write` for the producer; +- Homebrew and Scoop publication request `contents: write` and + `pull-requests: write` for one destination repository; and +- native package dispatch requests `contents: write` for the central receiver. + +Release Please uses the App token in the producer repository for its release PR, +tag, and initial draft. + +## Store the Actions variable and secret + +In the adopting organization's **Settings** > **Secrets and variables** > +**Actions**, create: + +| Kind | Name used by the maintained example | Value | +| --- | --- | --- | +| Variable | `MEIGMA_RELEASE_APP_CLIENT_ID` | The adopter-owned App client ID. | +| Secret | `MEIGMA_RELEASE_APP_PRIVATE_KEY` | The adopter-owned App private key PEM. | + +Limit both entries to **Selected repositories** and add the producer +repositories that mint tokens. A tap, bucket, or central receiver does not need +direct access to the private key; the producer's workflow mints a token scoped +to that destination. + +The names above are caller-local identifiers retained by the maintained +example. They do not identify the App owner. You may rename them if you update +every corresponding `vars.*` and `secrets.*` expression in the copied caller. + +GitHub never returns a stored Actions secret value. Verify its name, selected +repository list, and a workflow that successfully creates an installation +token. Never print the private key or commit it to a repository. + +## Allow the pinned workflows and actions + +In the organization and each producer, central receiver, tap, and bucket +repository, open **Settings** > **Actions** > **General**. Enable Actions and +choose an allowlist policy that permits the reviewed release unit. + +At minimum, allow: + +- the reusable workflows in `meigma/release` at the selected full commit SHA; +- `actions/cache`, `actions/checkout`, `actions/upload-artifact`, + `actions/download-artifact`, `actions/github-script`, + `actions/create-github-app-token`, and `actions/attest`; +- `jdx/mise-action`; +- `docker/setup-qemu-action`; +- `Homebrew/actions/setup-homebrew` when a producer generates a cask or a tap + validates one; +- `potatoqualitee/psmodulecache` when a Scoop bucket validates a manifest; and +- `googleapis/release-please-action` from the copied versioning workflow. + +Review the exact action pins in the selected `meigma/release` revision before +adding them. The workflows pin third-party actions to full commit SHAs. Do not +replace them with moving tags to satisfy an allowlist. + +If the organization allows only actions owned by the organization, add explicit +exceptions for this reviewed set. Apply the same policy to adopter-owned taps +and buckets so their generated validation workflows can call +`meigma/release`. + +## Set the workflow-token ceilings + +Organization, enterprise, and repository policy must permit the job-level +permissions declared by the release caller. The maintained caller starts with +`permissions: {}` and grants each called job only its required ceiling. + +Confirm that policy permits: + +- `attestations: read` for jobs that verify the installed `release-cli`; +- `id-token: write` for checksum signing, OCI signing, and attestations; +- `artifact-metadata: write` and `attestations: write` for publishers; +- `packages: write` for the GHCR publisher; and +- the Release Please job's `contents`, `issues`, and `pull-requests` writes. + +A reusable workflow cannot elevate beyond its calling job. Do not move these +permissions to a broad top-level grant. The App token, rather than +`GITHUB_TOKEN`, owns release, tap, bucket, and dispatch mutations. + +The **Allow GitHub Actions to create and approve pull requests** setting is not +required for Homebrew or Scoop: those pull requests are created with the App +token, and the publisher never approves or merges them. + +## Configure tag protection + +If a repository or organization ruleset restricts `v*` tags, add the +adopter-owned App as a bypass actor for tag creation. Keep the rule active for +other actors. + +A draft rehearsal can require an authorized operator to move one unpublished +tag to a reviewed recovery commit. Do not weaken production tag rules for this. +Use a disposable repository for the first tutorial or define a separate, +audited break-glass path for an unpublished candidate. Never grant routine tag +movement after publication. + +## Configure GHCR policy + +Enable GitHub Packages for the organization and permit the caller's +`packages: write` ceiling. The OCI publisher always writes +`ghcr.io//`; it does not accept a custom +registry name. + +Initial GHCR visibility follows the organization's package-creation policy, not +the source repository's visibility. After the first complete publication, +inspect it: + +```bash +export REPOSITORY=acme/widget +gh api "orgs/${REPOSITORY%%/*}/packages/container/${REPOSITORY#*/}" \ + --jq .visibility +``` + +The supported public-delivery state is `public`. If the result is `private`, an +organization owner must inspect the digest, signatures, and attestations and +then change visibility in the package settings UI. The current GitHub Packages +REST API does not expose this visibility change. + +The organization is ready when the App installation, selected-repository +variable and secret, Actions allowlist, tag rule, permission ceilings, and +Packages policy all include the intended producer. diff --git a/docs/how-to/rehearse-and-recover-github-releases.md b/docs/how-to/rehearse-and-recover-github-releases.md deleted file mode 100644 index f333635..0000000 --- a/docs/how-to/rehearse-and-recover-github-releases.md +++ /dev/null @@ -1,360 +0,0 @@ -# Rehearse and recover GitHub Releases - -Use this guide to populate a draft GitHub Release without publishing it, then resume publication through the same tag and draft. Complete [Configure GitHub Releases](configure-github-releases.md) first. The [GitHub Release contract](../reference/github-release-contract.md) defines the checks that each run enforces. - -The documented workflow revision is `0fc99489d31d400bc3f69d6636d60e7d3f3d0251` (`v0.1.3`). - -`release-cli publish github` owns draft discovery, tag and commit binding, expected-asset upload, asset convergence, and the optional undraft operation. It never creates a release, re-drafts a public release, or deletes an asset. - -## Prerequisites - -Before starting a rehearsal, confirm that: - -- the release configuration and organization credentials are present on the default branch; -- the Meigma Release App has selected-repository access; -- any protected `v*` tag rule permits the App to create release tags; -- the candidate version has no existing public release; and -- you can update the rehearsal tag if resumption requires moving it to a recovery commit. - -Tag updates are a separate permission from App-created tag creation. If a ruleset prevents you from updating a rehearsal tag, do not weaken the production rule solely for this procedure. Perform the rehearsal in a disposable repository or use an organization-approved break-glass process. - -Record the consumer repository: - -```bash -gh auth status -export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" -``` - -## 1. Configure a draft-only run - -In `.github/workflows/release.yml`, disable both publishers: - -```yaml -publish-image: false -publish-release: false -``` - -The copyable example already uses both values. Merge the change into the default branch before Release Please creates the candidate tag. The tag must contain the rehearsal caller; changing an untagged branch after the tag exists does not change that run. - -Confirm the value on the default branch: - -```bash -git fetch origin "$DEFAULT_BRANCH" -git show "origin/$DEFAULT_BRANCH:.github/workflows/release.yml" | - grep -E 'publish-(image|release): false' -``` - -The command must print both disabled inputs. Do not start the release if either is missing. - -## 2. Create the candidate tag and draft - -Release Please needs a releasable Conventional Commit after the version recorded in `.release-please-manifest.json`. When that condition is met, dispatch it: - -```bash -gh workflow run release-please.yml \ - --repo "$REPOSITORY" \ - --ref "$DEFAULT_BRANCH" -gh run list \ - --repo "$REPOSITORY" \ - --workflow release-please.yml \ - --limit 5 -``` - -Review and squash-merge the Release Please pull request. The subsequent Release Please run creates the `v*` tag and matching draft through the Release App. The tag triggers the Release workflow. - -Set `TAG` to the exact tag created by Release Please. The unmodified new example creates `v0.1.0` first: - -```bash -export TAG=v0.1.0 -git fetch origin "refs/tags/$TAG:refs/tags/$TAG" -export TAG_SHA="$(git rev-list -n 1 "$TAG")" -gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$TAG_SHA" \ - --event push \ - --limit 100 \ - --json databaseId,headBranch,headSha,event,status,url -``` - -After the exact tag and commit appear, assert that the query selects one run and watch it: - -```bash -test "$(gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$TAG_SHA" \ - --event push \ - --limit 100 \ - --json databaseId \ - --jq 'length')" -eq 1 -export RELEASE_RUN_ID="$(gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$TAG_SHA" \ - --event push \ - --limit 100 \ - --json databaseId \ - --jq '.[0].databaseId')" -gh run watch "$RELEASE_RUN_ID" \ - --repo "$REPOSITORY" \ - --compact \ - --exit-status -``` - -At the documented current revision, a successful draft-only run leaves the Release workflow green, the release unpublished with six platform archives, six native Linux packages, twelve SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`, and GHCR unchanged. With `publish-release: false`, the workflow runs `release-cli publish github --no-undraft`; the CLI converges and verifies the expected asset set, then confirms that the release remains a draft. The run also retains the verified multi-architecture layout, signed APK repository, apko lock, and image SBOMs in the `oci-image` workflow artifact. When rehearsing another revision, use the target contracts in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). - -## 3. Inspect the populated draft - -Query the authoritative releases collection for the exact tag. This matches the CLI's draft-discovery path and keeps the inspection query aligned with publication: - -```bash -test "$(gh api --paginate --slurp \ - "repos/$REPOSITORY/releases?per_page=100" \ - --jq "[.[][] | select(.tag_name == \"$TAG\")] | length")" -eq 1 -export RELEASE_ID="$(gh api --paginate --slurp \ - "repos/$REPOSITORY/releases?per_page=100" \ - --jq "[.[][] | select(.tag_name == \"$TAG\")][0].id")" -gh api "repos/$REPOSITORY/releases/$RELEASE_ID" \ - --jq '{id, tag_name, draft, prerelease, assets: [.assets[] | {name, state, digest}]}' -``` - -For the documented current revision, the query must select exactly one release with the exact tag, `"draft": true`, `"prerelease": false`, and 26 assets. Twenty-four asset names come from `checksums.txt`; the other two are the checksum manifest and its Cosign bundle. For an upgrade rehearsal, require the names and count defined by the target contract instead. - -If this rehearsal query reports `"draft": false`, stop. A `publish-release: false` run requires the release to remain a draft, so `publish github --no-undraft` classifies an already-public release as indeterminate even when all assets match. Inspect how the release became public; do not rerun the rehearsal or ask the CLI to re-draft it. - -Use the paginated releases API above or the repository's Releases UI while the release remains a draft. This procedure does not use by-tag CLI commands for draft discovery. - -Before resuming, inspect the Release workflow log and the draft asset list. Do not manually publish the draft. The final workflow must perform digest verification immediately before publication. - -## 4. Resume through the same tag and draft - -Change both publication controls in `.github/workflows/release.yml`: - -```yaml -publish-image: true -publish-release: true -``` - -Submit and merge that change. Fetch the resulting default-branch commit and record it: - -```bash -git fetch origin "$DEFAULT_BRANCH" --tags -export RECOVERY_SHA="$(git rev-parse "origin/$DEFAULT_BRANCH")" -printf 'Recovery commit: %s\n' "$RECOVERY_SHA" -``` - -The `push`-on-tag caller has no manual dispatch input. A rerun of the original Actions run would use the original tagged workflow with both publishers disabled. To exercise the updated caller, move the same rehearsal tag to the recovery commit and push that tag update: - -```bash -git tag --force "$TAG" "$RECOVERY_SHA" -git push --force origin "refs/tags/$TAG" -``` - -Use this tag move only for the controlled unpublished rehearsal. Never move a tag for a published release. - -After the run for the exact tag and `RECOVERY_SHA` appears, assert that the query selects one run and watch it: - -```bash -gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$RECOVERY_SHA" \ - --event push \ - --limit 100 \ - --json databaseId,headBranch,headSha,event,status,url -test "$(gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$RECOVERY_SHA" \ - --event push \ - --limit 100 \ - --json databaseId \ - --jq 'length')" -eq 1 -export RESUME_RUN_ID="$(gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$RECOVERY_SHA" \ - --event push \ - --limit 100 \ - --json databaseId \ - --jq '.[0].databaseId')" -gh run watch "$RESUME_RUN_ID" \ - --repo "$REPOSITORY" \ - --compact \ - --exit-status -``` - -The new run builds and signs a new authoritative artifact for `RECOVERY_SHA`. After validating that bundle, `release-cli publish github` accepts only names from the expected closed set and replaces those expected names with `--clobber`. It refuses an unexpected asset and never deletes one. - -Confirm that publication reused the same release ID and changed its state: - -```bash -export FINAL_RELEASE_ID="$(gh release view "$TAG" \ - --repo "$REPOSITORY" \ - --json databaseId \ - --jq .databaseId)" -test "$FINAL_RELEASE_ID" = "$RELEASE_ID" -gh release view "$TAG" \ - --repo "$REPOSITORY" \ - --json tagName,isDraft,isPrerelease,publishedAt,url -``` - -The ID comparison must succeed. The final result must report `"isDraft": false` and the original tag name. Run the checksum, Cosign identity, and GitHub attestation verification commands in [Configure GitHub Releases](configure-github-releases.md#7-verify-the-published-release) against the final assets. - -## Diagnose a failed run - -Resolve the commit currently named by the unpublished tag, then select the failed push run for that exact tag and commit: - -```bash -git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force -export FAILED_SHA="$(git rev-list -n 1 "$TAG")" -gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$FAILED_SHA" \ - --event push \ - --status failure \ - --limit 100 \ - --json databaseId,headBranch,headSha,event,status,conclusion,url -test "$(gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$FAILED_SHA" \ - --event push \ - --status failure \ - --limit 100 \ - --json databaseId \ - --jq 'length')" -eq 1 -export FAILED_RUN_ID="$(gh run list \ - --repo "$REPOSITORY" \ - --workflow release.yml \ - --branch "$TAG" \ - --commit "$FAILED_SHA" \ - --event push \ - --status failure \ - --limit 100 \ - --json databaseId \ - --jq '.[0].databaseId')" -gh run view "$FAILED_RUN_ID" \ - --repo "$REPOSITORY" \ - --log-failed -``` - -When `publish github` reports an indeterminate release state, it writes this remediation hint to stderr: - -```text -inspect the release in GitHub and reconcile manually; do not rerun the publication blindly -``` - -Follow that hint if the log ends during or after the undraft operation or reports that a draft-only publication found a public release. Inspect the releases collection and asset details with the commands in [Inspect the populated draft](#3-inspect-the-populated-draft): - -- If exactly one matching release is still a draft, the failure occurred before a confirmed undraft. After inspecting the state, a rerun is safe when the authoritative artifact remains valid because the CLI reads the tag, release, and assets again and reconciles from that fresh state. -- If exactly one matching release is public and the failed run used `publish-release: true`, compare its asset count, names, states, and digests with the expected bundle. A later publish-enabled CLI invocation reports success without mutation only for an exact match. Any other public state remains indeterminate and requires human handling. -- If exactly one matching release is public and the failed run used `publish-release: false`, the rehearsal did not preserve its draft-only outcome. The state remains indeterminate even when the assets match. Do not rerun or re-draft the release. -- If no release or more than one release carries the tag, stop and resolve that state through the release incident process. - -If recovery changes source, workflow configuration, or tool pins, merge that correction, record its commit SHA, and trigger a new run by authorized movement of the unpublished tag to that commit. Then select the run by the exact tag and SHA as shown above. If the tag cannot be moved safely, abandon the incomplete candidate and cut a new one. When repository content is unchanged, the release remains a draft, and upstream build jobs succeeded, rerun only failed jobs with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed`; this preserves the authoritative artifacts from the original workflow run. Use a complete rerun only when an upstream artifact must be rebuilt, such as artifact expiry or an artifact-handoff failure. - -### Release artifact staging fails - -The producer runs `release-cli stage --profile go --dist dist` before either -Actions artifact upload. The command first invokes -`goreleaser release --clean --skip=publish`, with GoReleaser progress and -diagnostics routed to the workflow log. It then stops on an invalid checksum -claim or bundle, an invalid Linux binary selection, an escaped path, or a binary -that is not a regular executable file. The failed step writes the GoReleaser or -artifact diagnostic to stderr. - -Use the diagnostic and the [`release-cli` contract](../reference/release-cli-contract.md) -to inspect the generated `dist` files. Correct the source or GoReleaser -configuration instead of bypassing the check. If the correction changes -repository content, merge it and move the unpublished rehearsal tag to the new -commit as described above, or abandon the candidate and cut a new one. - -### The matching draft is missing - -`release-cli publish github` polls the releases collection for the current tag and reports that no draft release exists if none appears within its bounded discovery budget. - -1. Check the Release Please run that created the tag. -2. Query the releases collection with the command in [Inspect the populated draft](#3-inspect-the-populated-draft). -3. Confirm that the App installation and both organization credentials include the consumer repository. -4. Confirm that Release Please created both the exact tag and a draft with that tag. - -Do not create an unrelated draft to make the publisher proceed. Release Please owns the release notes, tag, and initial draft. If Release Please created the tag without its draft and cannot reconcile it, remove the incomplete unpublished candidate through an authorized incident process, then cut a new candidate from Release Please. - -### The tag moved or resolves to a different commit - -`release-cli publish github` requires the workflow's tag to resolve to `github.sha`. It fails when those values differ. - -1. Fetch the remote tag and inspect its commit: - - ```bash - git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force - git rev-list -n 1 "$TAG" - ``` - -2. Compare the result with the commit shown by the failed Actions run. -3. If the tag was intentionally advanced for resumption, do not rerun the stale draft-only run. Use the new run triggered by the tag update. -4. If the move was unintended, stop publication and follow the repository's release incident process. Do not silently move a published tag backward. - -A draft can be recovered after an authorized tag move because the CLI revalidates the tag, commit, unique release, and assets from fresh state. A published tag is immutable operational history; release a corrected version instead. - -### Artifact handoff fails - -The publisher rejects an invalid artifact ID, an expired artifact, a digest mismatch, or an artifact produced by another workflow run. - -If the repository does not need a correction, rerun the complete top-level workflow with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY"` so it builds a new authoritative artifact; do not substitute an artifact from another run. The producer and publisher must exchange the artifact ID and digest within one run. If the handoff failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit to create a new tag-triggered run. The authoritative artifact is retained for seven days; an expired artifact also requires a complete rerun. - -### Checksum or Cosign verification fails - -For the documented current revision, the publisher requires a nonempty `checksums.txt`, the exact closed payload list, matching payload hashes, a regular Cosign bundle file, issuer `https://token.actions.githubusercontent.com`, and this certificate identity: - -```text -https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -``` - -For an upgrade rehearsal, replace the current-revision identity with the target value described in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). - -Do not upload files manually or relax the identity. Correct the producer configuration or its pinned workflow reference, merge that correction, and move the unpublished tag to the correction commit so a new tag-triggered run builds and signs it. If authorized tag movement is unavailable, abandon the candidate and cut a new one. Expected draft assets can be replaced only after the new signed bundle passes validation. - -### The draft has unexpected assets - -`release-cli publish github` stops before upload when the draft contains an asset name absent from the expected closed set. - -1. Inspect the draft in the Releases UI and through the releases API. -2. Determine who uploaded the asset and whether it belongs to the candidate. -3. If the asset is not part of the release contract, remove it manually from the draft with an authorized account. -4. If the asset is required, change the producer so the signed checksum manifest includes it. Merge the correction and move the unpublished tag to the correction commit, or abandon the candidate and cut a new one. - -Do not use `--clobber` for an unexpected name. The CLI uses `--clobber` only for the expected closed name set after checksum and signature validation. - -### Uploaded asset digest verification fails - -`release-cli publish github` waits until every expected asset is uploaded and GitHub reports its digest. It then requires the exact asset count, unique names, and a GitHub-reported SHA-256 digest matching the locally validated bundle. - -If no repository content changes, the release is still a draft, and the producer succeeded, rerun only failed jobs with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed`. The CLI re-reads current state and may replace expected names in the same draft. If the failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit; otherwise abandon the candidate and cut a new one. If the failure repeats, leave the release as a draft and inspect the release asset state and workflow logs; do not publish through the UI. Manual removal is required only when an unexpected or otherwise unreconcilable asset prevents the CLI from restoring the closed name set. - -If the undraft request fails or may have succeeded before a later failure, the CLI cannot prove the final state and reports it as indeterminate. It writes the remediation hint in [Diagnose a failed run](#diagnose-a-failed-run). Inspect the release and assets; do not rerun blindly. For a publish-enabled run, a public release with the exact expected asset set can be reported as success by a later invocation without mutation. If the public asset set differs, the state remains indeterminate. For a draft-only run, every public state is indeterminate, including an exact asset match. The CLI never re-drafts the release. - -## When manual cleanup is required - -Manual cleanup is required when: - -- an unexpected asset must be removed from a draft; -- an incomplete unpublished tag or release prevents Release Please from creating the correct candidate; -- repository rules require an authorized administrator to approve the controlled rehearsal tag move; or -- a draft contains a release association or asset state that the validated `--clobber` path cannot reconcile. - -Manual cleanup is not required for an expected asset from the first draft-only run or a failed checksum/signature check. If the release remains a draft, rerun only failed jobs when upstream artifacts remain valid and repository content is unchanged; the CLI reconciles from fresh state. Use a complete rerun for an expired or invalid artifact. For a source, configuration, or pin correction, merge the correction and trigger a new tag run or abandon the candidate. - -If a release is already public, do not delete it or move its tag as routine recovery. Preserve the published record and release a corrected version unless the organization declares a separate release incident and explicitly authorizes removal. diff --git a/docs/how-to/set-up-homebrew-tap.md b/docs/how-to/set-up-homebrew-tap.md deleted file mode 100644 index 5364cc9..0000000 --- a/docs/how-to/set-up-homebrew-tap.md +++ /dev/null @@ -1,160 +0,0 @@ -# Set up a Homebrew tap - -Use this guide to create a cask-only Homebrew tap and connect a producer repository to the shared publisher. The [`release-cli` contract](../reference/release-cli-contract.md#homebrew-tap-initialization) defines the generated files and failure behavior. - -## Prerequisites - -Confirm that: - -- the producer already builds a Homebrew cask with GoReleaser; -- a released, attestation-verified `release-cli` is installed; -- GitHub CLI is authenticated; -- you can create repositories and rulesets in the target organization; and -- an organization owner can change the Meigma Release App installation and Actions credentials. - -Use a public repository named `homebrew-`. Homebrew users will install casks from `owner/`. - -## 1. Generate the tap - -Choose the repository and an absent or empty local directory: - -```bash -export TAP_REPOSITORY=acme/homebrew-tools -export TAP_DIRECTORY="$PWD/homebrew-tools" - -release-cli init homebrew-tap \ - --tap "$TAP_REPOSITORY" \ - --output "$TAP_DIRECTORY" -``` - -The command writes `.github/workflows/casks.yml`, `.github/dependabot.yml`, `Casks/.gitkeep`, and `README.md`. It does not create a `Formula/` directory. The reusable validation workflow is pinned to the full source commit of the installed CLI. - -Inspect the output before creating the repository: - -```bash -find "$TAP_DIRECTORY" -type f -print | sort -``` - -Do not run the command over an existing checkout. A nonempty output directory is rejected rather than merged or overwritten. - -## 2. Create the GitHub repository - -Initialize and publish the generated directory: - -```bash -cd "$TAP_DIRECTORY" -git init -git add . -git commit -m "chore: initialize Homebrew tap" -git branch -M main -gh repo create "$TAP_REPOSITORY" --public --source=. --remote=origin --push -``` - -In the tap repository's **Settings** > **Actions** > **General**, confirm that Actions is enabled and that the Actions policy permits `meigma/release` and the actions used by its reusable workflow. - -## 3. Grant the Release App access - -An organization owner must add both the producer and tap repositories to the Meigma Release App installation: - -1. Open the organization settings in GitHub. -2. Open **Third-party access** > **GitHub Apps** > **Installed GitHub Apps**. -3. Configure the Meigma Release App. -4. Under **Repository access**, keep **Only select repositories** selected. -5. Add the producer repository and the new tap repository, then save. - -The App must have `contents: write` and `pull requests: write` for the tap. The publisher mints a short-lived token scoped to that repository. It does not use a personal access token. - -## 4. Grant the producer its Actions credentials - -In the organization settings, open **Secrets and variables** > **Actions**: - -1. Make the variable `MEIGMA_RELEASE_APP_CLIENT_ID` available to the producer repository. -2. Make the secret `MEIGMA_RELEASE_APP_PRIVATE_KEY` available to the producer repository. -3. Keep both resources limited to selected repositories. - -The tap does not need either credential. Its validation workflow is secret-free. - -## 5. Configure cask generation - -Add a `homebrew_casks` entry to the producer's `.goreleaser.yaml`. Keep `skip_upload: true`; `release-cli publish homebrew`, not GoReleaser, owns the tap pull request. - -```yaml -homebrew_casks: - - name: example - ids: - - example - binaries: - - example - repository: - owner: acme - name: homebrew-tools - homepage: https://github.com/acme/example - description: Example command - license: MIT - url: - template: "https://github.com/acme/example/releases/download/{{ .Tag }}/{{ .ArtifactName }}" - skip_upload: true -``` - -Replace every example value. The cask name must use lowercase letters, digits, and interior hyphens. - -## 6. Add the publisher job - -Add a job after the public GitHub Release job in the producer's tag workflow. Pin `publish-homebrew.yml` and `checksum-signing-workflow-ref` to the same full `meigma/release` commit used by the other shared release workflows. - -```yaml - homebrew-publish: - name: Open Homebrew tap pull request - needs: - - release-assets - - github-release - permissions: - actions: read - attestations: read - contents: read - uses: meigma/release/.github/workflows/publish-homebrew.yml@ - with: - artifact-id: ${{ needs.release-assets.outputs.artifact-id }} - artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@ - tap: acme/homebrew-tools - cask: example - release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-homebrew: true - secrets: - release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} -``` - -Keep `publish-homebrew: false` until the tap settings are ready. The publisher runs only after `github-release` succeeds, so generated cask URLs point to public release assets. - -## 7. Protect the tap branch - -The publisher opens a pull request and never merges it. Protect `main` before merging the first generated cask: - -1. In the tap repository, open **Settings** > **Rules** > **Rulesets**. -2. Create a branch ruleset targeting the default branch. -3. Require changes through a pull request. -4. Require the `casks / Homebrew cask validation` status check. -5. Block force pushes and branch deletion. -6. Activate the ruleset. - -GitHub may not offer the status check until it has run once. If necessary, enable the publisher for one real release, wait for its tap pull request and checks, configure the ruleset, and only then merge the pull request. - -## 8. Verify the first publication - -For the first real release: - -1. Set `publish-homebrew: true` before the release tag is created. -2. Confirm that the GitHub Release becomes public. -3. Confirm that the producer workflow opens one tap pull request. -4. Wait for `casks / Homebrew cask validation` to pass. -5. Review and merge the tap pull request. -6. Install and remove the cask on both Apple silicon and Intel macOS where those archives are published. - -```bash -brew install --cask acme/tools/example -example version -brew uninstall --cask example -``` - -A failed publisher run does not merge or auto-merge a tap pull request. Correct the producer or tap configuration, then rerun the failed job. diff --git a/docs/how-to/set-up-package-repository.md b/docs/how-to/set-up-package-repository.md deleted file mode 100644 index adb12c8..0000000 --- a/docs/how-to/set-up-package-repository.md +++ /dev/null @@ -1,188 +0,0 @@ -# Set up the shared package repository - -Use this guide to publish verified DEB, RPM, and APK packages from producer GitHub Releases to a static Cloudflare R2 repository. The [package repository contract](../reference/package-repository-contract.md) defines the accepted release, configuration, object layout, trust checks, and recovery behavior. After publication, give consumers the [native package installation guide](install-release-cli-from-package-repositories.md). - -## Prerequisites - -You need: - -- a central GitHub repository that owns the package policy and public keys; -- a Cloudflare R2 bucket with a public custom domain such as `https://pkgs.meigma.dev`; -- R2 S3 credentials limited to listing, reading, and writing objects in that bucket; -- one passphrase-protected OpenPGP private key for aggregate APT and RPM metadata; -- one RSA private key for aggregate APK indexes; -- the RPM and APK public signing keys for every producer; -- a GitHub App installation that can send `repository_dispatch` events to the central repository. - -Do not give a producer workflow R2 credentials or aggregate signing keys. Store those credentials only in the central repository's `packages-production` environment. - -## Add the policy and public keys - -Create `.config/package-repository.yaml` in the central repository: - -```yaml -channel: stable -origin: https://pkgs.meigma.dev -keys: - apt: - source: keys/repository.asc - published: apt-repository-001.asc - rpm: - source: keys/repository.asc - published: rpm-repository-001.asc - apk: - source: keys/repository-apk.rsa.pub - published: apk-index-001.rsa.pub -producers: - - repository: meigma/release - packages: - - release-cli - checksum_workflow: .github/workflows/go-pre-publish.yml - attestation_workflow: .github/workflows/publish-github-release.yml - rpm_key: - source: keys/release-rpm.asc - published: release-rpm-001.asc - apk_key: - source: keys/release-apk.rsa.pub - published: release-apk-001.rsa.pub -``` - -Add the referenced public keys beneath `.config/keys/`. `source` paths are relative to `.config/`. `published` values are flat filenames under the public repository's `keys/` path. - -The aggregate APT and RPM entries may use the same OpenPGP public-key source, as shown above. Each published filename must remain unique. Do not commit a private key or passphrase. - -Commit the policy and public keys through the central repository's normal review path. - -## Configure R2 - -Create one R2 bucket for the repository. Attach the public custom domain at the bucket root; the publication command rejects origins with a path prefix, query, fragment, credentials, or non-HTTPS scheme. - -Keep the default R2 endpoint private. Clients use only the custom domain. The publisher uses the S3-compatible endpoint derived from the Cloudflare account ID. - -Create credentials scoped to the package bucket with these object operations: - -- list; -- read; -- write. - -Delete permission is not required. Publication never deletes objects. - -Add these repository variables to the central GitHub repository: - -| Variable | Value | -|---|---| -| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account that owns the bucket | -| `PACKAGE_REPOSITORY_R2_BUCKET` | R2 bucket name | - -## Configure the production environment - -Create a GitHub environment named `packages-production`. Add required reviewers and restrict who can change its configuration. - -Add these environment secrets: - -| Secret | Content | -|---|---| -| `R2_ACCESS_KEY_ID` | R2 S3 access key ID | -| `R2_SECRET_ACCESS_KEY` | R2 S3 secret access key | -| `PACKAGE_REPOSITORY_GPG_PRIVATE_KEY` | Base64-encoded armored OpenPGP private key | -| `PACKAGE_REPOSITORY_GPG_PASSPHRASE` | OpenPGP key passphrase | -| `PACKAGE_REPOSITORY_APK_PRIVATE_KEY` | Base64-encoded APK RSA private key | - -Encode each key as a single-line value: - -```bash -base64 < repository-private-key.asc | tr -d '\n' -base64 < repository-apk.rsa | tr -d '\n' -``` - -The setup action writes the decoded values to owner-only files on the ephemeral runner. It imports exactly one OpenPGP primary secret key and removes the decoded import file before publication. - -## Add the central dispatcher workflow - -Create `.github/workflows/publish-package-release.yml` in the central repository. Replace `` with a reviewed full commit SHA from `meigma/release`. - -```yaml -name: Publish package release - -on: - repository_dispatch: - types: - - package-release - -permissions: - attestations: read - contents: read - -jobs: - publish: - uses: meigma/release/.github/workflows/publish-package-repository.yml@ - with: - repository: ${{ github.event.client_payload.repository }} - tag: ${{ github.event.client_payload.tag }} - cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - r2-bucket: ${{ vars.PACKAGE_REPOSITORY_R2_BUCKET }} -``` - -The reusable workflow selects the central repository's `packages-production` environment. Environment secrets are not passed by the producer. - -Keep this workflow on the central repository's default branch. GitHub runs `repository_dispatch` workflows only when their workflow file exists on the default branch. - -## Dispatch from a producer - -Add the reusable dispatch job after the producer's public GitHub Release job. -Keep it disabled during setup: - -```yaml -package-repository: - name: Request package repository publication - needs: github-release - permissions: {} - uses: meigma/release/.github/workflows/request-package-repository.yml@583937edadfbae183e49f16df46b98e0b36807ba # v0.1.16 - with: - package-repository-owner: meigma - package-repository-name: pkgs - release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-package-repository: false - secrets: - release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} -``` - -The reusable workflow mints a short-lived token scoped to the central -repository and sends only the producer repository and exact tag. The GitHub App -installation needs repository Contents write permission on `meigma/pkgs` so it -can create `repository_dispatch` events. It does not receive R2 credentials or -aggregate signing keys. - -After the central policy, keys, environment, and receiver workflow are ready, -change `publish-package-repository` to `true`. Keep the job ordered after the -public GitHub Release; the receiver rejects a missing, draft, or mismatched -release. - -## Verify the first publication - -Dispatch one already-published producer tag. The central workflow must finish successfully before you configure clients. - -Check the public metadata and keys: - -```bash -curl --fail --silent --show-error https://pkgs.meigma.dev/apt/dists/stable/InRelease >/dev/null -curl --fail --silent --show-error https://pkgs.meigma.dev/rpm/stable/x86_64/repodata/repomd.xml >/dev/null -curl --fail --silent --show-error https://pkgs.meigma.dev/apk/stable/main/x86_64/APKINDEX.tar.gz >/dev/null -curl --fail --silent --show-error https://pkgs.meigma.dev/keys/apt-repository-001.asc >/dev/null -``` - -The workflow also installs the requested package version through APT, DNF, and -APK before and after upload. The DNF and APK checks load both the aggregate -metadata or index key and the producer package-signing key. A successful -workflow is the installation acceptance check. - -## Replay a failed publication - -Dispatch the same `{repository, tag}` pair again. Publication is convergent: - -- matching immutable objects are skipped; -- missing objects are uploaded; -- replaceable metadata is regenerated and uploaded; -- a different value at an immutable path fails instead of overwriting that object. - -Do not delete or rename package objects to recover a run. Fix the failed prerequisite, then replay the same request. A successful replay reports `state: unchanged` when every generated object already matches R2. diff --git a/docs/how-to/set-up-scoop-bucket.md b/docs/how-to/set-up-scoop-bucket.md deleted file mode 100644 index f3698d1..0000000 --- a/docs/how-to/set-up-scoop-bucket.md +++ /dev/null @@ -1,176 +0,0 @@ -# Set up a Scoop bucket - -Use this guide to create a Scoop bucket and connect a producer repository to the shared publisher. The [`release-cli` contract](../reference/release-cli-contract.md#scoop-bucket-initialization) defines the generated files and failure behavior. - -## Prerequisites - -Confirm that: - -- the producer already builds a Scoop manifest with GoReleaser; -- a released, attestation-verified `release-cli` is installed; -- GitHub CLI is authenticated; -- you can create repositories and rulesets in the target organization; and -- an organization owner can change the Meigma Release App installation and Actions credentials. - -Use a public repository. Keep manifests at the repository root because the publisher writes `.json` there and the validation workflow inspects root `*.json` files. `scoop bucket list` counts only a `bucket/` subdirectory and therefore reports `Manifests 0` for this layout; use search, install, and the required validation check to verify the bucket. - -## 1. Generate the bucket - -Choose the repository and an absent or empty local directory: - -```bash -export BUCKET_REPOSITORY=acme/scoop-tools -export BUCKET_DIRECTORY="$PWD/scoop-tools" - -release-cli init scoop-bucket \ - --bucket "$BUCKET_REPOSITORY" \ - --output "$BUCKET_DIRECTORY" -``` - -The command writes `.gitattributes`, `.github/workflows/manifests.yml`, `.github/dependabot.yml`, and `README.md`. The reusable validation workflow is pinned to the full source commit of the installed CLI. `.gitattributes` makes text files use CRLF in Windows checkouts, as required by the pinned Scoop bucket tests. - -Inspect the output before creating the repository: - -```bash -find "$BUCKET_DIRECTORY" -type f -print | sort -``` - -Do not run the command over an existing checkout. A nonempty output directory is rejected rather than merged or overwritten. - -## 2. Create the GitHub repository - -Initialize and publish the generated directory: - -```bash -cd "$BUCKET_DIRECTORY" -git init -git add . -git commit -m "chore: initialize Scoop bucket" -git branch -M main -gh repo create "$BUCKET_REPOSITORY" --public --source=. --remote=origin --push -``` - -In the bucket repository's **Settings** > **Actions** > **General**, confirm that Actions is enabled and that the Actions policy permits `meigma/release` and the actions used by its reusable workflow. - -## 3. Grant the Release App access - -An organization owner must add both the producer and bucket repositories to the Meigma Release App installation: - -1. Open the organization settings in GitHub. -2. Open **Third-party access** > **GitHub Apps** > **Installed GitHub Apps**. -3. Configure the Meigma Release App. -4. Under **Repository access**, keep **Only select repositories** selected. -5. Add the producer repository and the new bucket repository, then save. - -The App must have `contents: write` and `pull requests: write` for the bucket. The publisher mints a short-lived token scoped to that repository. It does not use a personal access token. - -## 4. Grant the producer its Actions credentials - -In the organization settings, open **Secrets and variables** > **Actions**: - -1. Make the variable `MEIGMA_RELEASE_APP_CLIENT_ID` available to the producer repository. -2. Make the secret `MEIGMA_RELEASE_APP_PRIVATE_KEY` available to the producer repository. -3. Keep both resources limited to selected repositories. - -The bucket does not need either credential. Its validation workflow is secret-free. - -## 5. Configure manifest generation - -Add a `scoops` entry to the producer's `.goreleaser.yaml`. Keep `skip_upload: true`; `release-cli publish scoop`, not GoReleaser, owns the bucket pull request. - -```yaml -scoops: - - name: example - ids: - - example - repository: - owner: acme - name: scoop-tools - homepage: https://github.com/acme/example - description: Example command - license: MIT - url_template: "https://github.com/acme/example/releases/download/{{ .Tag }}/{{ .ArtifactName }}" - skip_upload: true -``` - -Replace every example value. The manifest name must use lowercase letters, digits, and interior hyphens. Configure GoReleaser's archive names so the manifest selects both Windows AMD64 and ARM64 assets when the producer ships both architectures. - -## 6. Add the publisher job - -Add a job after the public GitHub Release job in the producer's tag workflow. Pin `publish-scoop.yml` and `checksum-signing-workflow-ref` to the same full `meigma/release` commit used by the other shared release workflows. - -```yaml - scoop-publish: - name: Open Scoop bucket pull request - needs: - - release-assets - - github-release - permissions: - actions: read - attestations: read - contents: read - uses: meigma/release/.github/workflows/publish-scoop.yml@ - with: - artifact-id: ${{ needs.release-assets.outputs.artifact-id }} - artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@ - bucket: acme/scoop-tools - manifest: example - release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-scoop: true - secrets: - release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} -``` - -Keep `publish-scoop: false` until the bucket settings are ready. The publisher runs only after `github-release` succeeds, so generated manifest URLs point to public release assets. - -## 7. Protect the bucket branch - -The publisher opens a pull request and never merges it. Protect `main` before merging the first generated manifest: - -1. In the bucket repository, open **Settings** > **Rules** > **Rulesets**. -2. Create a branch ruleset targeting the default branch. -3. Require changes through a pull request. -4. Require the `manifests / Scoop manifest validation` status check. -5. Block force pushes and branch deletion. -6. Activate the ruleset. - -GitHub may not offer the status check until it has run once. If necessary, enable the publisher for one real release, wait for its bucket pull request and checks, configure the ruleset, and only then merge the pull request. - -Dependabot updates `.github/workflows/manifests.yml`, so the manifest-only workflow does not report the required check on those pull requests. Do not bypass branch protection. After the bucket contains a manifest, reproduce the action update on a maintainer branch and include a semantically neutral formatting change to one root manifest so the Scoop validation runs. Before the first manifest exists, defer the action update. - -## 8. Verify publication and the Scoop lifecycle - -For the first real release: - -1. Set `publish-scoop: true` before the release tag is created. -2. Confirm that the GitHub Release becomes public. -3. Confirm that the producer workflow opens one bucket pull request and changes only `.json`. -4. Wait for `manifests / Scoop manifest validation` to pass on both Windows AMD64 and ARM64. -5. Review and merge the bucket pull request. -6. On clean Windows AMD64 and ARM64 systems, add the bucket, install the app, run its version command, uninstall it, and remove the bucket. - -```powershell -scoop bucket add scoop-tools https://github.com/acme/scoop-tools -scoop install scoop-tools/example -example version -scoop uninstall example -scoop bucket rm scoop-tools -``` - -Verify updates after publishing and merging the next release. Install the earlier version from the bucket commit that contained it, return the bucket checkout to `main`, then update and uninstall the app: - -```powershell -scoop bucket add scoop-tools https://github.com/acme/scoop-tools -$bucket = Join-Path $env:USERPROFILE 'scoop\buckets\scoop-tools' -git -C $bucket checkout -scoop install scoop-tools/example -git -C $bucket checkout main -git -C $bucket pull --ff-only -scoop update example -example version -scoop uninstall example -scoop bucket rm scoop-tools -``` - -Run the update check on Windows AMD64 and ARM64 when both archives are published. A failed publisher run does not merge or auto-merge a bucket pull request. Correct the producer or bucket configuration, then rerun the failed job. diff --git a/docs/how-to/upgrade-github-release-workflows.md b/docs/how-to/upgrade-github-release-workflows.md deleted file mode 100644 index 2f0cb38..0000000 --- a/docs/how-to/upgrade-github-release-workflows.md +++ /dev/null @@ -1,275 +0,0 @@ -# Upgrade GitHub Release workflows - -Use this guide to move a consumer repository from its current workflow revision to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md), [OCI image contract](../reference/oci-image-contract.md), and [`release-cli` contract](../reference/release-cli-contract.md) define the current interfaces and publication boundaries. - -## Prerequisites - -Before changing the consumer repository, confirm that: - -- the target `meigma/release` commit has completed review; -- the target commit is available in the public `meigma/release` repository; -- the consumer currently passes its required repository checks; -- `mise`, Git, GitHub CLI, and the repository-approved `actionlint` installation are available locally; and -- you can perform a draft-only release and, if necessary, update its unpublished rehearsal tag. - -Record the consumer, the current baseline, and a local checkout of `meigma/release`: - -```bash -export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -read -r -p 'Current full meigma/release commit SHA: ' CURRENT_RELEASE_REVISION -read -r -p 'Reviewed full meigma/release commit SHA: ' NEW_RELEASE_REVISION -export NEW_RELEASE_REVISION -read -r -p 'Path to the meigma/release checkout: ' RELEASE_CHECKOUT -export RELEASE_CHECKOUT -[[ "$CURRENT_RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] -[[ "$NEW_RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] -test "$NEW_RELEASE_REVISION" != "$CURRENT_RELEASE_REVISION" -test "$(gh api "repos/meigma/release/commits/$NEW_RELEASE_REVISION" --jq .sha)" = \ - "$NEW_RELEASE_REVISION" -test "$(git -C "$RELEASE_CHECKOUT" rev-parse --is-inside-work-tree)" = true -git -C "$RELEASE_CHECKOUT" fetch \ - origin \ - "$CURRENT_RELEASE_REVISION" \ - "$NEW_RELEASE_REVISION" -test "$(git -C "$RELEASE_CHECKOUT" rev-parse "$NEW_RELEASE_REVISION^{commit}")" = \ - "$NEW_RELEASE_REVISION" -``` - -The commands must succeed. They prevent a shortened, mistyped, unavailable, or locally unresolved revision from entering the caller. - -## 1. Assess the contract change - -API compare summaries and patches can omit or truncate relevant content. Review a local Git diff between the exact commit objects instead: - -```bash -git -C "$RELEASE_CHECKOUT" diff \ - --no-ext-diff \ - --find-renames \ - "$CURRENT_RELEASE_REVISION^{commit}" \ - "$NEW_RELEASE_REVISION^{commit}" \ - -- \ - .github/workflows/go-pre-publish.yml \ - .github/workflows/go-oci-build.yml \ - .github/workflows/publish-github-release.yml \ - .github/workflows/publish-oci-image.yml \ - .github/workflows/release.yml \ - .github/actions/setup-release-cli/action.yml \ - .github/workflows/release-please.yml \ - docs/reference/github-release-contract.md \ - docs/reference/oci-image-contract.md \ - docs/reference/release-cli-contract.md \ - examples/go-release -``` - -Read both complete target contracts after reviewing the diff: - -```bash -git -C "$RELEASE_CHECKOUT" show \ - "$NEW_RELEASE_REVISION:docs/reference/github-release-contract.md" -git -C "$RELEASE_CHECKOUT" show \ - "$NEW_RELEASE_REVISION:docs/reference/oci-image-contract.md" -``` - -Before adoption, identify changes to: - -- reusable workflow inputs, outputs, secrets, and caller permissions; -- the `release-cli` commands, flags, exit codes, and result fields used by the workflows; -- the setup action's acquisition behavior and version and protocol checks; -- checksum signer and attestation identities; -- artifact handoff, payload names, SBOMs, checksums, and publication states; -- consumer source and GoReleaser configuration requirements; -- GitHub App credentials, tag rules, or other external prerequisites; -- runner and mise requirements; and -- required Go, GoReleaser, Syft, Cosign, GitHub CLI, Melange, apko, or ORAS versions. - -Stop if the target revision removes a required consumer capability or if any migration or rollback step is unresolved. Do not infer compatibility from an unchanged workflow filename. - -## 2. Apply the target contract atomically - -At the target revision, moving the GoReleaser invocation into -`release-cli stage --profile go` requires no consumer caller interface change -beyond updating the pinned revision. The command invokes exactly -`goreleaser release --clean --skip=publish`. Keep `release.disable: true` in -`.goreleaser.yaml`; it is a second publication control, and Release Please -continues to own release notes and the initial draft. Existing reusable workflow -inputs and outputs stay unchanged. - -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@...` -2. `uses: meigma/release/.github/workflows/go-oci-build.yml@...` -3. `uses: meigma/release/.github/workflows/publish-oci-image.yml@...` -4. `uses: meigma/release/.github/workflows/publish-github-release.yml@...` -5. `checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@...` - -Keep both `publish-image: false` and `publish-release: false` for the upgrade rehearsal. All four reusable workflow references and the checksum signing identity must change in the same pull request and commit. A mixed revision fails a signing boundary or runs contracts that were not reviewed together. - -The one full commit SHA selects the workflows, their composite setup action, and -the `release-cli` version used by those workflows. Do not add a separate CLI -version setting or CLI path. A consumer repository automatically installs the -verified CLI release stamped into that revision. - -Apply every other target-contract change in that same upgrade: - -- update caller permissions, inputs, output consumption, and secrets; -- update source, GoReleaser, asset, and other repository configuration requirements; -- update locked tools as described below; and -- complete required App installation, tag-rule, credential, or organization-setting changes before the upgraded workflow runs. - -External prerequisites cannot be committed atomically with repository files. Assign an owner and completion condition for each one, complete it in the documented order, and do not merge or trigger a release while the repository and external states describe different contracts. - -Check the edited caller: - -```bash -test "$(grep -F -c "$NEW_RELEASE_REVISION" .github/workflows/release.yml)" -eq 5 -! grep -F -q "$CURRENT_RELEASE_REVISION" .github/workflows/release.yml -grep -F 'publish-image: false' .github/workflows/release.yml -grep -F 'publish-release: false' .github/workflows/release.yml -``` - -All four commands must succeed: the caller must contain five target references, no baseline reference, and both disabled publication controls. - -Write a migration and rollback checklist in the upgrade pull request. It must record: - -- the current and target full commit SHAs; -- every caller interface, permission, source, asset, configuration, and tool change; -- every external prerequisite, its owner, and its observable completion result; -- the expected draft asset and identity checks; and -- the repository and external changes that reverse the entire upgrade before publication. - -Do not merge with an unchecked migration item or an unexecutable rollback item. - -## 3. Update locked tools only when required - -Compare the target contract's repository and toolchain requirements with `mise.toml` and `mise.lock` in the consumer. - -If the target contract keeps the current compatible tool versions, leave both files unchanged. Do not regenerate the lock merely because the workflow revision changed. - -If the target contract requires different tools or versions: - -1. Update only the required declarations in `mise.toml`. -2. Regenerate the supported-platform lock entries: - - ```bash - mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64 - ``` - -3. Include `mise.toml` and `mise.lock` in the same upgrade pull request as the caller. - -The target workflow revision and the tool lock must reach the default branch together. - -## 4. Validate the upgrade pull request - -Run the locked tool installation and GoReleaser configuration check: - -```bash -mise install --locked -mise exec -- goreleaser check -actionlint .github/workflows/release.yml .github/workflows/release-please.yml -``` - -All three commands must exit successfully. Use the repository's pinned or otherwise approved `actionlint` installation; do not add an unreviewed download command to the upgrade. - -Run the consumer repository's normal local check commands after these release-specific checks. Use the same build, test, lint, and policy entry points required for an ordinary pull request. Do not merge while any required check fails. - -Review the final diff and confirm that it contains no moving reusable workflow reference. Submit every repository change in the migration checklist as one pull request. Confirm the external prerequisites in the checklist before merging or triggering the rehearsal. - -## 5. Rehearse the target revision - -After the upgrade reaches the default branch, perform the draft-only procedure in [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). Do not enable either publisher until the target revision has populated and verified a draft and produced the expected OCI artifact. - -Use the linked guide for its release creation, exact run selection, draft lookup, resumption, and recovery mechanics. During an upgrade rehearsal, the target contract overrides every baseline constant in that guide. In particular, require the target asset names and count, the checksum certificate identity ending in `@$NEW_RELEASE_REVISION`, and the publisher `--signer-digest "$NEW_RELEASE_REVISION"`. Do not reject a target-compliant draft because it differs from the baseline 26-asset set or baseline revision. - -The rehearsal must produce these observable results: - -- Release Please creates the candidate `v*` tag and matching draft; -- the producer and publisher jobs run at `NEW_RELEASE_REVISION`; -- the draft contains exactly the assets allowed by the target contract; -- the checksum manifest validates every listed payload; and -- the release remains a draft. - -Use the authenticated GitHub CLI session to download every asset from the exact draft release ID recorded by the rehearsal guide. Run this Bash block from the consumer repository after setting `TAG`, `REPOSITORY`, and `RELEASE_ID`: - -```bash -set -euo pipefail -export DRAFT_ASSET_DIR="release-assets-upgrade-${TAG#v}" -test ! -e "$DRAFT_ASSET_DIR" -mkdir "$DRAFT_ASSET_DIR" -gh api --paginate --slurp \ - "repos/$REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" \ - --jq '.[][] | [.id, .name] | @tsv' \ - > "$DRAFT_ASSET_DIR/.assets.tsv" -test -s "$DRAFT_ASSET_DIR/.assets.tsv" -while IFS=$'\t' read -r asset_id asset_name; do - [[ "$asset_id" =~ ^[0-9]+$ ]] - [[ "$asset_name" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] - gh api \ - -H 'Accept: application/octet-stream' \ - "repos/$REPOSITORY/releases/assets/$asset_id" \ - > "$DRAFT_ASSET_DIR/$asset_name" -done < "$DRAFT_ASSET_DIR/.assets.tsv" -rm -- "$DRAFT_ASSET_DIR/.assets.tsv" -cd "$DRAFT_ASSET_DIR" -test -s checksums.txt -test -s checksums.txt.sigstore.json -if command -v sha256sum >/dev/null 2>&1; then - sha256sum --check checksums.txt -elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 --check checksums.txt -else - printf 'No SHA-256 checksum command is available.\n' >&2 - exit 1 -fi -``` - -Every listed payload must report `OK`. Verify the target checksum signer identity only after checksum verification succeeds: - -```bash -mise exec -- cosign verify-blob \ - --bundle checksums.txt.sigstore.json \ - --certificate-identity "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@$NEW_RELEASE_REVISION" \ - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ - checksums.txt -``` - -Cosign must exit successfully. An identity containing the current baseline or any other revision is an upgrade failure. - -Verify each checksummed payload against the target publisher revision. Set `TAG` and `REPOSITORY` to the candidate values before running this command: - -```bash -while IFS= read -r entry; do - test -n "$entry" || continue - asset="${entry:66}" - mise exec -- gh attestation verify "$asset" \ - --repo "$REPOSITORY" \ - --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ - --signer-digest "$NEW_RELEASE_REVISION" \ - --source-ref "refs/tags/$TAG" \ - --deny-self-hosted-runners -done < checksums.txt -``` - -Every invocation must exit successfully. `--signer-digest` binds the reusable publisher to the reviewed target commit; the source-ref constraint binds the attestation to the consumer's candidate tag. - -After these checks pass, change both `publish-image` and `publish-release` to `true` and resume through the same tag and populated draft as described in the rehearsal guide. The resume run must use `NEW_RELEASE_REVISION` in all five caller locations. - -## Roll back before publication - -If no candidate tag exists, reverse every repository and external-prerequisite change recorded in the migration checklist. Restore caller interfaces and permissions, workflow references, source and asset configuration, tool declarations and lock entries, App access, tag rules, credentials, and organization settings to their prior states. Apply the repository rollback in one pull request, sequence external rollback steps so the restored workflow remains operable, and run the validation commands again before merging. - -If the target revision has populated an unpublished draft: - -1. Reverse every repository change in the migration checklist in one rollback commit, including all four `uses:` entries, `checksum-signing-workflow-ref`, caller permissions and interfaces, source and asset configuration, and tool pins and lock entries. -2. Restore every changed external prerequisite to the prior state recorded in the checklist. Sequence those changes so the rollback workflow remains operable, and record each observable restored state. -3. Keep both `publish-image: false` and `publish-release: false`. -4. Run the locked install, GoReleaser check, actionlint, and repository checks against the complete rollback. -5. Move the same unpublished tag to the rollback commit and trigger a new top-level Release run, following the recovery procedure. -6. Verify the restored asset contract, Cosign identity, and GitHub signer digest before enabling publication. - -The validated rollback run may replace expected asset names with `--clobber`. If the target revision added asset names that the previous signed manifest does not allow, those names are unexpected during rollback and block upload. Remove them manually from the draft only after confirming that they came from the abandoned target revision. Do not delete and recreate the draft. - -## Correct after publication - -A public release is no longer a recoverable draft. The publisher rejects it, and there is no workflow rollback that can safely replace its assets or move its tag. - -Preserve the public release and tag. Restore or advance the entire workflow contract for future releases in a reviewed pull request, including caller interfaces, permissions, source and asset configuration, tools, and applicable external prerequisites. Run the same validation and draft rehearsal, then publish a corrected new version. Delete or rewrite a public release only through an explicitly authorized release-incident process. diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md deleted file mode 100644 index fd5f8ef..0000000 --- a/docs/reference/github-release-contract.md +++ /dev/null @@ -1,491 +0,0 @@ -# GitHub release contract reference - -This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `0fc99489d31d400bc3f69d6636d60e7d3f3d0251` (`v0.1.3`). - -For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). The [`release-cli` contract](release-cli-contract.md) defines the command, output, and exit-code surface used by the producer. The [OCI image contract](oci-image-contract.md) defines the image builder and publisher that gate the complete delivery caller. [Release trust boundaries](../explanation/release-trust-boundaries.md) explains why the workflows, setup action, and CLI have separate responsibilities. To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). - -## Canonical workflow references - -The released cross-repository GitHub Release path pins its four reusable -workflows to one full revision. Its producer and GitHub publisher references -are: - -```yaml -uses: meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -``` - -```yaml -uses: meigma/release/.github/workflows/publish-github-release.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -``` - -The checksum signer identity input must name the same producer workflow revision: - -```yaml -checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -``` - -## Caller contract - -The supported caller runs on creation or movement of a `v*` tag. Every reusable workflow rejects a non-tag ref. Tag deletion events must not start the producer job. The GitHub Release publisher waits for successful image build and publication so a registry failure leaves the release draft unpublished. - -```yaml -name: Release - -on: - push: - tags: - - 'v*' - -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - release-assets: - name: Build release assets - if: github.event.deleted == false - permissions: - attestations: read - contents: read - id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 - - oci-image: - name: Build OCI image - needs: release-assets - permissions: - actions: read - attestations: read - contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 - with: - artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} - artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} - - oci-publish: - name: Publish OCI image - needs: oci-image - permissions: - actions: read - artifact-metadata: write - attestations: write - contents: read - id-token: write - packages: write - uses: meigma/release/.github/workflows/publish-oci-image.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 - with: - artifact-id: ${{ needs.oci-image.outputs.artifact-id }} - artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} - image-digest: ${{ needs.oci-image.outputs.image-digest }} - publish-image: true - - github-release: - name: Publish GitHub Release - needs: - - release-assets - - oci-image - - oci-publish - permissions: - actions: read - artifact-metadata: write - attestations: write - contents: read - id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 - with: - artifact-id: ${{ needs.release-assets.outputs.artifact-id }} - artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 - require-oci-image: true - oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} - release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-release: true - secrets: - release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} -``` - -The repository's production caller adds the Scoop publisher after the public -GitHub Release: - -```yaml - scoop-publish: - name: Open Scoop bucket pull request - needs: - - release-assets - - github-release - permissions: - actions: read - attestations: read - contents: read - uses: ./.github/workflows/publish-scoop.yml - with: - artifact-id: ${{ needs.release-assets.outputs.artifact-id }} - artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: ${{ github.repository }}/.github/workflows/go-pre-publish.yml@${{ github.ref }} - bucket: meigma/scoop-bucket - manifest: meigma-release-cli - release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-scoop: true - secrets: - release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} -``` - -`scoop-publish` and `homebrew-publish` both require `release-assets` and -`github-release`. They can run independently after the public release succeeds. -Neither package publisher can run when the GitHub Release job fails. - -The top-level `permissions: {}` prevents permissions from being granted implicitly. Each called job grants its reusable workflow only the permissions listed above. A called workflow cannot elevate permissions beyond those granted by its caller. - -The caller concurrency key serializes runs for the same workflow and tag. `cancel-in-progress: false` prevents a later run for that tag from canceling an earlier run. The OCI publisher adds repository-wide serialization across different release tags so shared channel tags cannot race. - -## Reusable workflow interfaces - -### `go-pre-publish.yml` - -The Go producer accepts two default-off booleans: - -- `sign-and-notarize-macos` controls Darwin signing and notarization. Enabling it requires the five optional `macos-*` secrets. -- `sign-native-packages` controls RPM and APK signing. Enabling it requires `rpm-signing-key`, `rpm-signing-passphrase`, `apk-signing-key`, and `apk-signing-passphrase`. The key secrets contain base64-encoded private keys. - -Disabled signing requires none of the corresponding secrets. When native signing is enabled, the workflow validates all four secrets before setup, writes the decoded keys to owner-only files under `RUNNER_TEMP` immediately before staging, and removes the key directory after the stage step even when staging fails. - -The producer loads `setup-release-cli` from the same pinned release revision -with `uses: $/.github/actions/setup-release-cli`. The caller does not pin the -action or CLI separately. - -| Output | Value | -| --- | --- | -| `artifact-id` | ID returned by `actions/upload-artifact` for the `release-assets` artifact. | -| `artifact-url` | URL returned for the `release-assets` artifact. | -| `artifact-digest` | SHA-256 digest returned for the `release-assets` artifact. | - -The job requires these caller permissions: - -| Permission | Access | Use | -| --- | --- | --- | -| `attestations` | `read` | Verify the released `release-cli` archive for consumer repositories; self-release source builds do not use this permission. | -| `contents` | `read` | Check out the consumer repository and its tag history. | -| `id-token` | `write` | Obtain the OIDC identity used by keyless Cosign signing. | - -The workflow runs on `ubuntu-24.04` with a 20-minute timeout. It declares `permissions: {}` at workflow scope, so the caller must grant the job permissions explicitly. Its `release-assets` artifact is retained for seven days and is uploaded with compression disabled. - -Before the tag gate, the producer validates each enabled signing configuration. Its remaining sequence is: - -1. Install Go, GoReleaser, Syft, and Cosign with mise. -2. Verify that `go`, `goreleaser`, `syft`, and `cosign` resolve to mise-managed executables, then report the Go version. This workflow-owned proof does not build the release bundle. -3. Run `setup-release-cli` from the same pinned release revision. -4. When native signing is enabled, decode the RPM and APK private keys into owner-only temporary files. -5. Resolve the pinned GoReleaser executable with `mise which goreleaser`, require it to be executable, and pass it through `RELEASE_GORELEASER_PATH`. Run `release-cli stage --profile go --dist dist` under `mise exec`. The stage command validates the native key paths and passphrases before GoReleaser starts. -6. Remove the temporary native private keys. -7. Upload the canonical Linux binary artifact and the authoritative release asset artifact. - -The producer contains no direct GoReleaser command. `release-cli stage --profile go` owns the build, native package signing, checksum generation, validation, and OCI input projection. GoReleaser signs RPM and APK packages before it generates `checksums.txt`, so the checksum manifest identifies the signed package bytes. - -### `publish-github-release.yml` - -| Input | Type | Required | Default | Value | -| --- | --- | --- | --- | --- | -| `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. | -| `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. | -| `require-oci-image` | boolean | No | `false` | Whether public GitHub Release publication requires a validated digest-pinned GHCR image reference for the caller repository. | -| `oci-image-reference` | string | No | Empty | `ghcr.io//@sha256:` returned by the successful OCI publisher. | - -| Secret | Required | Value | -| --- | --- | --- | -| `release-app-private-key` | Yes | Private key used with `release-app-client-id` to mint the Release App installation token. | - -| Output | Value | -| --- | --- | -| `attestation-url` | URL returned by the GitHub build-provenance attestation step. | -| `release-url` | HTML URL of the populated release, whether it remains a draft or is published. | - -The publisher job requires these caller permissions: - -| Permission | Access | Use | -| --- | --- | --- | -| `actions` | `read` | Read and download the authoritative Actions artifact. | -| `artifact-metadata` | `write` | Write metadata used by GitHub artifact attestations. | -| `attestations` | `write` | Create GitHub build-provenance attestations. | -| `contents` | `read` | Check out the consumer repository at the tag. Draft and release operations use the App token instead. | -| `id-token` | `write` | Obtain the OIDC identity for GitHub build-provenance attestations. | - -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. Run `setup-release-cli` from the same pinned release revision. -2. Run `release-cli verify handoff --artifact-id --digest `. -3. Download the artifact with the SHA-pinned `actions/download-artifact` step and `digest-mismatch: error`. -4. Run `release-cli verify bundle --dist dist --identity https://github.com/ --json`. -5. Create the GitHub build-provenance attestation with `dist/checksums.txt` as `subject-checksums`. -6. Run `release-cli publish github --dist dist --json`. When `publish-release` is `false`, also pass `--no-undraft`. - -The final CLI command rebuilds the expected closed asset set from `dist`, binds the tag to the workflow commit, discovers the release, uploads and converges expected assets, and conditionally makes the release public. Its result URL becomes the workflow's `release-url` output. - -`release-cli verify bundle` must succeed before the attestation step runs, and the attestation must succeed before `publish github` can upload an asset. This preserves the verify, attest, then upload ordering. - -The workflow mints the short-lived Release App installation token with `actions/create-github-app-token` and passes it to the CLI as `RELEASE_APP_TOKEN`. The CLI holds the value as a redacted secret. It does not receive the App private key or client ID and does not mint a token. - -### `publish-scoop.yml` - -| Input | Type | Required | Default | Value | -| --- | --- | --- | --- | --- | -| `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`. | -| `checksum-signing-workflow-ref` | string | Yes | None | Exact owner, repository, workflow path, and revision used as the checksum certificate identity. | -| `bucket` | string | No | Empty | Target Scoop bucket in `owner/repository` form. Required when publication is enabled. | -| `manifest` | string | No | Empty | GoReleaser manifest name without `.json`. Required when publication is enabled. | -| `release-app-client-id` | string | No | Empty | Release App client ID. Required when publication is enabled. | -| `publish-scoop` | boolean | No | `false` | Whether to reconcile the generated manifest through a bucket pull request. | - -| Secret | Required | Value | -| --- | --- | --- | -| `release-app-private-key` | No | Release App private key. Required only when `publish-scoop` is `true`. | - -| Output | Value | -| --- | --- | -| `branch` | Deterministic `release//v` branch. | -| `pull-request-url` | Open bucket pull request URL for `created` and `open`; empty for `published`. | -| `state` | One of `created`, `open`, or `published`. | - -The job requires `actions: read`, `attestations: read`, and `contents: read` -from its caller. The default-off job is skipped before configuration -validation, token creation, or a bucket request. When enabled, it verifies the -artifact metadata and transport digest, requires exactly -`scoop/.json`, removes both package-manager controls from the bundle -verification view, and verifies the signed bundle. It restores only the Scoop -control before minting a short-lived App token scoped to the selected bucket -repository with `contents: write` and `pull-requests: write`. - -The workflow then runs `release-cli publish scoop`. It accepts only the three -documented states and requires a branch for every state and a pull request URL -for `created` or `open`. It never writes the bucket's default branch, merges a -pull request, enables auto-merge, deletes a ref, or changes repository policy. - -## 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`. - -The supported Release Please configuration has these release-boundary values: - -| Setting | Current value | Contract effect | -| --- | --- | --- | -| `release-type` | `go` | Applies Release Please's Go versioning strategy. | -| Manifest version | `0.0.0` | Records that no release has been published. | -| `initial-version` | `0.1.0` | Selects the first proposed release version. | -| `include-v-in-tag` | `true` | Produces tags accepted by the caller's `v*` filter. | -| `include-component-in-tag` | `false` | Produces an unscoped version tag. | -| `force-tag-creation` | `true` | Creates the release tag when the release is cut. | -| `draft` | `true` | Creates the draft required by the publisher. | -| `bump-minor-pre-major` | `true` | Uses a minor bump for pre-1.0 features. | -| `bump-patch-for-minor-pre-major` | `true` | Uses a patch bump for pre-1.0 fixes. | - -The initial version and pre-1.0 bump rules are current versioning policy, not reusable-workflow defaults. The publisher requires exactly one release for the tag and normally requires it to be a draft; it does not calculate a version or create either object. The only public-release success exception is a completed-publication rerun with `publish-release: true` whose assets exactly match the expected closed set. A public release under `publish-release: false` is indeterminate because the requested draft-only outcome was not preserved. - -Both versioning and publication use these organization-level credential identifiers: - -- Variable: `MEIGMA_RELEASE_APP_CLIENT_ID` -- Secret: `MEIGMA_RELEASE_APP_PRIVATE_KEY` - -The Meigma Release GitHub App must be installed on the consumer repository. Release publication requests an installation token with `contents: write`. Release Please also uses the App to update release pull requests and create the draft release and tag. If a repository protects `v*` tags, its rules must allow this App to bypass tag-creation restrictions. The App-created tag is the event that starts the release caller. - -## Tag and draft invariants - -The publisher proceeds only when all of these conditions hold: - -- `github.ref_type` is `tag`. -- The artifact ID is a positive safe integer. -- The artifact has not expired. -- The artifact belongs to the current workflow run. -- The artifact's GitHub-reported digest matches `artifact-digest`. -- `git rev-list -n 1 ` equals `github.sha` for the run. -- Exactly one GitHub Release has a `tag_name` equal to `github.ref_name`. -- The matching release is a draft before any mutation. -- Before upload, every existing asset name belongs to the expected closed set. - -The CLI polls the release list up to 24 times, with attempts 5 seconds apart. It fails instead of creating a missing release. More than one release for the tag is ambiguous and fails closed. If the one matching release is already public, the CLI performs no mutation. With `publish-release: true`, it reads the assets: an exact expected match reports a completed publication as success, and any other asset state is indeterminate. With `publish-release: false`, every already-public state is indeterminate before any asset read. - -## Repository and toolchain contract - -The producer checks out the consumer repository with full history and runs `release-cli stage --profile go` in that repository. The CLI invokes GoReleaser there, so the consumer supplies the Go module, command source, `.goreleaser.yaml`, `mise.toml`, and `mise.lock` used for the build. - -The repository must declare and lock these mise tool identifiers: - -- `go` -- `aqua:goreleaser/goreleaser` -- `aqua:anchore/syft` -- `aqua:sigstore/cosign` -- `aqua:cli/cli` - -The producer installs the first four tools. The publisher installs GitHub CLI and Cosign. Both workflows set `MISE_EXEC_AUTO_INSTALL=false` and resolve managed tools through mise; undeclared tools are not installed as a fallback. The publisher passes the Cosign path resolved by mise to `release-cli verify bundle` through `RELEASE_COSIGN_PATH`. The producer sets `GOTOOLCHAIN=local` and verifies that `go`, `goreleaser`, `syft`, and `cosign` resolve to their mise-managed executables. It then passes the managed GoReleaser path through `RELEASE_GORELEASER_PATH` and runs `release-cli stage` under mise's environment because GoReleaser shells out to Go, Syft, and Cosign. The setup action separately requires the runner's `gh` command with attestation support and fails closed if either is unavailable. - -The canonical workflows install mise `2026.8.8`. These repository pins are the current known-compatible baseline, not versions selected automatically by the reusable workflows: - -| Tool | Current repository pin | -| --- | --- | -| Go | `1.26.6` | -| GoReleaser | `2.17.1` | -| Syft | `1.51.0` | -| Cosign | `3.1.3` | -| GitHub CLI | `2.97.0` | - -The lock must contain entries that mise can install on the `ubuntu-24.04` runner. The workflows use the versions selected by the consumer repository's locked mise configuration. - -## GoReleaser contract - -`release-cli stage --profile go` invokes this command in the consumer repository before it validates the resulting bundle: - -```text -goreleaser release --clean --skip=publish -``` - -A compatible `.goreleaser.yaml` uses schema version 2 and writes the release bundle under the same distribution directory passed to `release-cli stage --dist`. The canonical workflow passes `--dist dist`. The supported Go profile has these requirements: - -- Build Darwin, Linux, and Windows binaries for `amd64` and `arm64` with `CGO_ENABLED=0`. -- Package Darwin and Linux binaries as `tar.gz`; package Windows binaries as `zip`. -- Package each canonical Linux binary as DEB, RPM, and APK through one GoReleaser nFPM definition. The package step reuses the build output and installs the binary in `/usr/bin`. -- Build with `-trimpath` and linker flags `-s -w -buildid=`. -- Populate `main.version` from `{{ .Version }}` and `main.commit` from `{{ .FullCommit }}`. -- Set `mod_timestamp` to `{{ .CommitTimestamp }}`. -- Use GoReleaser's module-proxy mode and the local Go toolchain. The current profile sets `GOPROXY=https://proxy.golang.org,direct` and `GOSUMDB=sum.golang.org` for module resolution. -- Emit one SBOM per archive and one per native package through GoReleaser's `artifacts: archive` and `artifacts: package` SBOM configurations. -- Write the SHA-256 manifest as `checksums.txt`. -- Sign `checksums.txt` with `cosign sign-blob --bundle=${signature} ${artifact} --yes` and name the bundle `checksums.txt.sigstore.json`. -- Disable GoReleaser changelog generation and the GoReleaser release pipe. Release Please owns release notes and the draft; the reusable publisher owns asset upload and publication. - -The GoReleaser invocation also supplies `--skip=publish`. `release.disable: true` is the repository requirement; the command-line skip is a second boundary against GoReleaser publication. - -The project name, command path, and binary name are consumer values. The [copyable example](../../examples/go-release/) uses `example`, `./cmd/example`, and `example`. They are not inputs to the reusable workflow. - -This repository's own project and binary name is `release-cli`, so its released -archive names start with `release-cli_`; for example, -`release-cli__linux_amd64.tar.gz`. Consumer repositories continue to -use their own project and binary names. - -## Authoritative artifact and asset contract - -The producer uploads one Actions artifact named `release-assets`. Its upload set -contains the signed release payload and two package-manager controls: - -```text -dist/*.tar.gz -dist/*.zip -dist/*.deb -dist/*.rpm -dist/*.apk -dist/*.sbom.json -dist/checksums.txt -dist/checksums.txt.sigstore.json -dist/homebrew/Casks/*.rb -dist/scoop/*.json -``` - -The supported three-operating-system, two-architecture Go payload contains six -archives, six native Linux packages, twelve SBOMs, `checksums.txt`, and -`checksums.txt.sigstore.json`: 26 signed/public files. This repository's -Homebrew cask and Scoop manifest increase the digest-protected Actions artifact -to 28 files without changing the signed or public payload count. - -Before upload, the producer obtains `release-cli` through the shared setup action and runs `release-cli stage --profile go --dist dist`. The command first builds the release bundle through GoReleaser. It then verifies every payload listed in `checksums.txt`, requires a nonempty regular `checksums.txt.sigstore.json`, verifies the two canonical Linux binaries described in the [`release-cli` contract](release-cli-contract.md), and writes the OCI input projection. - -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. `release-cli verify bundle` verifies the extracted content and the detached Sigstore bundle. - -The cask and Scoop manifest are Actions control files, not signed release -payloads. They are integrity-bound by the Actions artifact digest and must not -appear in `checksums.txt`, the build-provenance subjects, or the GitHub Release. -After handoff verification, the GitHub Release publisher removes both controls -before bundle verification, attestation, and upload. The Homebrew publisher -removes the Scoop control and temporarily isolates its cask; the Scoop publisher -removes the Homebrew control and temporarily isolates its manifest. Each -package publisher restores only its own control after the same signed bundle -passes verification. - -`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: - -```text -[A-Za-z0-9][A-Za-z0-9._+-]* -``` - -`release-cli verify bundle` enforces these rules: - -- The manifest contains at least one payload. -- Every payload name is unique. -- Payloads are regular files. Directories and symbolic links are rejected. -- Every listed payload exists and matches its recorded SHA-256 digest. -- `checksums.txt` and `checksums.txt.sigstore.json` are control files and cannot list themselves as payloads. -- The downloaded `dist` directory contains exactly the listed payloads and the two control files. Any other entry is rejected. - -`release-cli publish github` uploads the listed payloads plus the two control files. The GitHub Release must end with exactly that closed name set. Duplicate names, missing names, unexpected names, non-uploaded asset states, missing GitHub digests, or digest differences cause failure. - -GitHub build-provenance attestations use `dist/checksums.txt` as `subject-checksums`. The checksummed archives and SBOMs are attestation subjects. The checksum manifest and its Cosign bundle are uploaded control files, not entries in their own manifest. - -## Trust identities - -The checksum signature is accepted only when `release-cli verify bundle` invokes Cosign and verifies all of the following: - -| Field | Required value | -| --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251` | -| Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | -| Signed blob | `checksums.txt` | -| Bundle | `checksums.txt.sigstore.json` | - -The workflow adds the `https://github.com/` prefix to `checksum-signing-workflow-ref` and passes the resulting exact URL to `release-cli verify bundle` with `--identity`. A branch name, tag name, different commit, or different workflow path does not satisfy the documented identity. - -The publisher at `meigma/release/.github/workflows/publish-github-release.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. The workflow mints a short-lived Release App installation token with `contents: write` and passes it to `release-cli publish github`. The CLI stores the value as a redacted secret and uses it for release discovery and publication; it never mints an App token, changes repository settings, or persists credentials. - -## Publication states - -| State | Entry condition | Workflow behavior | Exit condition | -| --- | --- | --- | --- | -| Version prepared | Release Please runs on `main` or by manual dispatch. | Release Please updates its release pull request according to the manifest configuration. | The release change reaches `main`. | -| Draft created | Release Please cuts the version through the Release App. | Release Please creates the `v*` tag and matching draft release. | The App-created tag starts the release caller. | -| Artifact built | The producer runs on the tag. | `release-cli stage --profile go` invokes GoReleaser once to build the bundle, create SBOMs and checksums, and sign the checksum manifest. The producer then uploads `release-assets`. | The artifact ID and digest pass to the publisher in the same workflow run. | -| Draft populated | The publisher validates the artifact, signature, tag, and draft. | After attestation, `release-cli publish github` uploads the expected closed asset set and verifies every GitHub-reported asset state and digest. | Asset count, unique names, uploaded states, and digests match the signed bundle. | -| Rehearsal complete | `publish-release` is `false`. | The workflow passes `--no-undraft`; the CLI converges the draft and verifies that it remains a draft. | The populated draft is available for inspection or a later recovery run. | -| Published | `publish-release` is `true`, asset verification succeeds, and any required digest-pinned OCI image reference is valid. | The CLI changes the draft to public as its last mutation and verifies the resulting state. | The same release URL identifies a non-draft GitHub Release. | - -The publisher does not create a release, generate release notes, change a tag, re-draft a public release, or delete an asset. It does not make a draft public until the uploaded asset name and digest sets match the bundle. When `require-oci-image` is `true`, it also requires the successful OCI publisher's exact `ghcr.io//@sha256:` output before any release mutation. - -## Retry and recovery behavior - -`verify handoff` uses the [`release-cli` metadata request retry policy](release-cli-contract.md#metadata-request-retries). `publish github` uses the same four-attempt policy for retryable failures, with waits of 1 second, 2 seconds, and 4 seconds. It does not retry tag and commit mismatches, unexpected assets, or digest mismatches. - -Draft discovery makes up to 24 attempts, 5 seconds apart. Asset convergence makes up to 12 attempts, 1 second apart, for every expected asset to report an `uploaded` state and a digest. - -A failure before the undraft operation is safe to rerun while the release remains a draft and the run's authoritative artifact is still valid. The CLI reads the tag, release, and asset state again instead of assuming the previous attempt made no changes: - -- The publisher accepts only an unexpired artifact whose workflow run ID equals `github.run_id`; an artifact from another run cannot be supplied. A new tag-triggered run builds and signs its own artifact. -- Existing assets whose names are in the expected closed set may be replaced because upload uses `gh release upload --clobber`. -- Existing assets whose names are outside the expected closed set block upload. Neither the CLI nor the workflow deletes them. -- After upload, the CLI verifies the complete name set, uploaded states, and every GitHub-computed SHA-256 digest before it can make the release public. -- The tag must still resolve to `github.sha`, and exactly one release must still carry the tag. - -A complete draft rehearsal sets both `publish-image: false` and `publish-release: false`. The workflow invokes `publish github --no-undraft`, which converges the asset set and stops while the release remains a draft. To resume, the caller changes both inputs to `true`, commits that change, and uses authorized movement of the same unpublished tag name to trigger a new run against the existing populated draft; it does not delete and recreate the draft. The workflow replaces expected assets only after the new artifact, checksums, and Cosign bundle pass validation. Any source, workflow configuration, or tool-pin correction follows the same commit and tag-movement requirement. If the unpublished tag cannot be moved safely, the incomplete candidate must be abandoned and a new candidate cut. A plain Actions rerun is reserved for failures that require no repository-content change, such as artifact expiry or a transient service failure. - -An undraft request has no rollback. A failure from the undraft call is indeterminate because the update may have applied. A failed final state read or an unexpected final draft flag is also indeterminate because the CLI cannot prove the requested outcome. An operator must inspect the release and its assets instead of rerunning blindly. A later invocation with `publish-release: true` against an already-public release reports success without mutation only when the release has the exact expected asset count, names, uploaded states, and digests. Under `publish-release: false`, an already-public release remains indeterminate even when its assets match, because a rehearsal must remain a draft. Any other public state also requires human handling. The CLI never re-drafts the release. - -## Non-goals - -This contract does not provide or imply: - -- OCI construction or publication behavior beyond the dependency ordering defined here; see the separate [OCI image contract](oci-image-contract.md). -- Package-manager repository validation and reconciliation beyond the dependency and control-file boundaries defined here. See the [`release-cli` contract](release-cli-contract.md) for Homebrew and Scoop behavior. -- Native package repository publication or native package-manager signing. DEB, RPM, and APK files are standalone GitHub Release assets protected by the release checksum, Cosign, and attestation contract. -- Release support for languages other than the documented Go producer profile. -- Consumer CI policy or tests in the OIDC-enabled release job. -- Release-note generation in GoReleaser. -- Automatic creation of a missing draft, deletion of unexpected assets, or rollback after publication. -- Repository ruleset, immutable-release, branch-protection, or credential provisioning automation. -- Automatic adoption by existing repositories. diff --git a/docs/reference/oci-image-contract.md b/docs/reference/oci-image-contract.md deleted file mode 100644 index 9a349d9..0000000 --- a/docs/reference/oci-image-contract.md +++ /dev/null @@ -1,312 +0,0 @@ -# OCI image contract - -This page defines the cross-repository contract for the reusable Go OCI builder and GHCR publisher at revision `0fc99489d31d400bc3f69d6636d60e7d3f3d0251` (`v0.1.3`). - -For adoption steps, see [Configure OCI image publication](../how-to/configure-oci-images.md). The [GitHub Release contract](github-release-contract.md) defines the upstream Go release producer and GitHub Release publisher. A complete consumer is available in the [Go release example](../../examples/go-release/). - -## Pipeline boundary - -```text -release-cli stage --profile go - -> GoReleaser-built, CLI-validated linux/amd64 and linux/arm64 binaries - -> verified oci-build-inputs artifact - -> release-cli signed APK repositories - -> release-cli locked apko multi-architecture OCI layout - -> release-cli verified layout, runtime contract, and architecture SBOMs - -> verified oci-image artifact - -> release-cli digest-addressed GHCR preparation and recursive Cosign signatures - -> GitHub and registry provenance/SBOM attestations - -> release-cli verified tag finalization - -> public GitHub Release -``` - -The image builder consumes the canonical binaries built and selected by `release-cli stage --profile go`. During staging, the CLI invokes GoReleaser, validates its outputs, and records the canonical binary paths and digests. `release-cli image build` uses Melange to package those binaries without compiling, stripping, or otherwise replacing them, then uses apko to compose the OCI layout. `release-cli image verify` checks the completed layout, runtime contract, and architecture SBOMs before upload. The publisher consumes that layout and does not check out or execute consumer repository code. - -## Reusable workflows - -Consumers call both workflows at the same immutable revision: - -```yaml -uses: meigma/release/.github/workflows/go-oci-build.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -``` - -```yaml -uses: meigma/release/.github/workflows/publish-oci-image.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 -``` - -Moving branches and tags are not supported workflow references. - -## Builder interface - -`go-oci-build.yml` accepts these inputs: - -| Input | Required | Default | Meaning | -| --- | --- | --- | --- | -| `artifact-id` | yes | none | Numeric ID of the verified `oci-build-inputs` artifact from `go-pre-publish.yml`. | -| `artifact-digest` | yes | none | GitHub artifact SHA-256 digest for the `oci-build-inputs` artifact. | -| `melange-config` | no | `melange.yaml` | Consumer-relative Melange configuration path. | -| `apko-config` | no | `apko.yaml` | Consumer-relative apko configuration path. | - -It returns: - -| Output | Meaning | -| --- | --- | -| `artifact-id` | Numeric ID of the authoritative `oci-image` artifact. | -| `artifact-url` | GitHub URL for the authoritative artifact. | -| `artifact-digest` | GitHub artifact SHA-256 digest. This covers the uploaded ZIP transport, not the OCI index. | -| `image-digest` | `sha256:` digest of the exact bytes in `layout/index.json`. | - -The caller grants `actions: read`, `attestations: read`, and `contents: read`. `attestations: read` lets consumer repositories verify the released `release-cli` archive; the release repository's source build does not use it. A called workflow can never exceed its caller's ceiling. The builder has no registry credentials, package write permission, attestation write permission, or release credential. - -After the tag gate, checkout, mise, QEMU, and tool proof, the builder's relevant sequence is: - -1. Run `setup-release-cli`. -2. Run `release-cli verify handoff --artifact-id --digest `. -3. Download the `oci-build-inputs` artifact with the SHA-pinned `actions/download-artifact` step and `digest-mismatch: error`. -4. Run `release-cli image build` to verify the projected canonical binary digests, build the signed APK repositories, and compose the locked OCI layout. -5. Run `release-cli image verify` to check the layout, runtime invariants, architecture SBOMs, and digest of the exact OCI index bytes. -6. Upload the verified `oci-image` artifact. - -`release-cli image verify` writes `image-digest.txt` in the authoritative output root. The builder workflow delegates all image-contract checks to this command and contains no shell verification logic. The upload step remains unchanged and includes `image-digest.txt` in the `oci-image` artifact. - -The producer artifact is named `oci-build-inputs`. It contains `oci-build-inputs.json` beside `artifacts.json` and the canonical `linux/amd64` and `linux/arm64` binary trees. The projection records the shared binary name, confined path, and SHA-256 digest for each platform. Before packaging, `release-cli image build` recomputes both digests from the downloaded bytes and requires them to match the projection. - -## Publisher interface - -`publish-oci-image.yml` accepts these inputs: - -| Input | Required | Default | Meaning | -| --- | --- | --- | --- | -| `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. | -| `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. | - -It returns: - -| Output | Meaning | -| --- | --- | -| `image-name` | Canonical `ghcr.io/owner/repository` name. | -| `image-reference` | Digest-pinned published reference, or empty when publication is disabled. | -| `image-digest` | Verified OCI index digest, regardless of publication mode. | -| `provenance-attestation-url` | GitHub provenance attestation URL, or empty when publication is disabled. | -| `amd64-sbom-attestation-url` | GitHub amd64 SBOM attestation URL, or empty when publication is disabled. | -| `arm64-sbom-attestation-url` | GitHub arm64 SBOM attestation URL, or empty when publication is disabled. | - -The caller grants: - -```yaml -permissions: - actions: read - artifact-metadata: write - attestations: write - contents: read - id-token: write - packages: write -``` - -`packages: write` permits the in-memory `release-cli` registry client, Cosign, and registry-backed GitHub attestations to write 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. Run `setup-release-cli`. -2. Run `release-cli verify handoff --artifact-id --digest `. -3. Download the authoritative OCI image with the SHA-pinned `actions/download-artifact` step and `digest-mismatch: error`. -4. Verify the OCI layout contents and expose the image, version, index, and platform values used by later steps. -5. When `publish-image` is `true`, log in to GHCR for Cosign and registry-backed attestations. -6. Run `release-cli publish oci prepare`. Publication runs push and sign the digest-addressed image; verification-only runs add `--dry-run` and make no registry writes. The workflow captures the command's JSON envelope for finalization. -7. When `publish-image` is `true`, run the three SHA-pinned `actions/attest` steps for index provenance and the two platform SBOMs. -8. When `publish-image` is `true`, pipe the captured prepare envelope to `release-cli publish oci finalize --result -`. -9. After a publication attempt, remove the GHCR entry from the Docker configuration even when an earlier publication step failed. - -The workflow sets `image-reference` only for a publication run. It remains empty when `publish-image` is `false`. - -## Consumer configuration - -### GoReleaser - -`release-cli stage --profile go` invokes GoReleaser and then requires exactly one canonical Linux binary for each pair: - -| GOOS | GOARCH | GOAMD64 | -| --- | --- | --- | -| `linux` | `amd64` | `v1` | -| `linux` | `arm64` | unset | - -The binaries must be static and executable. The OCI builder rejects a missing target, duplicate target, wrong architecture, symlink, non-executable file, or checksum mismatch. - -### Melange - -`melange.yaml` must: - -- declare `x86_64` and `aarch64` target architectures; -- use `${{vars.version}}` as the package version; -- install the staged file named `application` as the intended command; -- preserve mode `0755` and ownership `0:0`; and -- name the package consumed by `apko.yaml`. - -`release-cli image build` injects the stable tag version and writes an ephemeral APK signing key. The workflow artifact retains the public key, signed APKs, signed repository indexes, package SBOMs, and Melange provenance. The private signing key is never uploaded. - -### apko - -`apko.yaml` must: - -- consume the Melange package; -- define `amd64` and `arm64` only; -- set exactly one executable entrypoint; -- run as numeric user and group `65532` through the `nonroot` account; -- include source, title, description, and SPDX license annotations; and -- include the runtime files the command requires. - -The current example includes Alpine's CA certificate bundle. A command that does not make TLS connections may deliberately omit it after testing; a command that needs other runtime data must declare the corresponding package explicitly. - -## Authoritative artifact - -The builder uploads `oci-image` with seven-day retention and no additional ZIP compression. `release-cli image verify` writes `image-digest.txt` before the upload. The artifact contract includes: - -```text -apko.lock.json -apk-signing.rsa.pub -configuration/apko.yaml -configuration/melange.yaml -image-digest.txt -layout/index.json -layout/oci-layout -layout/blobs/sha256/* -packages/aarch64/* -packages/x86_64/* -sboms/sbom-aarch64.spdx.json -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. - -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. The publisher's SHA-pinned `actions/github-script` content verifier checks the extracted OCI artifact. It requires 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. - -`release-cli verify handoff` does not download the artifact and never reproduces the Actions ZIP digest. - -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 - -### Name - -The image name is derived from the caller repository: - -```text -ghcr.io// -``` - -Custom registry hosts, namespaces, and image names are outside the current contract. - -### Tags - -A stable release tag `vMAJOR.MINOR.PATCH` publishes: - -| Image tag | Behavior | -| --- | --- | -| `MAJOR.MINOR.PATCH` | Immutable exact release version. Publication fails before any registry upload when this tag already resolves to a different digest. | -| `MAJOR.MINOR` | Advances only when the candidate is a greater stable version in the same minor line. | -| `MAJOR` | Advances only when the candidate is a greater stable version in the same major line. | -| `latest` | Advances only when the candidate is greater than its current stable version. | - -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. `release-cli publish oci prepare` resolves and validates every existing tag before uploading the image. `release-cli publish oci finalize` re-reads the registry before it applies any tag. 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 used during publication. It can run independently to inspect the decisions for a candidate release. The publisher does not call this standalone inspection command. - -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. - -The publisher runs `release-cli publish oci prepare`, the three GitHub attestation actions, and then `release-cli publish oci finalize`. Prepare publishes and signs the digest-addressed image without creating or moving a tag. Finalize compares fresh registry state with the prepare observations, recomputes the tag plan, applies tags serially, and verifies their resolutions. - -Trust metadata strictly precedes every public tag. No exact or channel tag is created or moved until recursive signing and all three attestations complete. See [Why OCI publication has two phases](../explanation/two-phase-oci-publication.md) for the security and failure model. - -Digest-pinned references are the durable consumer interface: - -```text -ghcr.io/owner/repository@sha256: -``` - -### Runtime invariants - -For both platforms, `release-cli image verify` checks: - -- one OCI image index with exactly two Linux platform manifests, one for `amd64` and one for `arm64`; -- `org.opencontainers.image.source`, `org.opencontainers.image.version`, `org.opencontainers.image.revision`, `org.opencontainers.image.title`, `org.opencontainers.image.description`, and `org.opencontainers.image.licenses` annotations, including the expected source, version, and revision and nonempty values for title, description, and licenses; -- platform manifest annotations and config labels equal to the index annotations; -- one layer per platform manifest and a config whose operating system and architecture match the platform; -- entrypoint `/usr/bin/` and runtime user `65532`; -- a regular `usr/bin/` layer entry with ownership exactly `0:0` and mode exactly `0755`, with no setuid, setgid, or sticky bit set; -- executable bytes equal to the corresponding canonical staged binary; and -- an SPDX `APPLICATION` package at `-r0` in each architecture SBOM. - -## Signatures and attestations - -The publisher signs the index and both platform manifests with Cosign keyless signing. Verification must require: - -| Field | Value | -| --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251` | -| Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | -| Subject | Digest-pinned image or platform manifest. | - -The publisher also creates: - -- one SLSA provenance attestation for the multi-architecture index; -- one SPDX SBOM attestation for the amd64 platform manifest; and -- one SPDX SBOM attestation for the arm64 platform manifest. - -Each attestation is written to the consumer repository's GitHub attestation store and pushed to GHCR as an OCI referrer. Verification should constrain the reusable signer workflow, signer revision, consumer source tag, and GitHub-hosted runner as shown in the configuration guide. - -Cosign signatures and GitHub attestations are distinct. A passing check for one does not prove the other exists. - -## Publication states - -| State | Registry effect | Expected next action | -| --- | --- | --- | -| `publish-image: false` | None. The workflow runs `publish oci prepare --dry-run` for layout, digest, state, and tag-plan verification. It applies no tags, and `image-reference` remains empty. | Inspect the rehearsal and enable both publication controls in one reviewed commit. | -| Prepare incomplete | Untagged, digest-addressed blobs or manifests may exist. No release tag has been created or changed. GitHub Release remains draft. | Rerun only failed jobs so the publisher reuses the authoritative artifact from the same workflow run. | -| Signing incomplete | The digest-addressed image may exist, but no release tag has been created or changed. GitHub Release remains draft. | Rerun only failed jobs; do not advertise or make the package public. | -| Attestation incomplete | The digest is signed but does not satisfy the complete contract. No release tag has been created or changed. GitHub Release remains draft. | Rerun only failed jobs and verify every attestation. | -| Finalize incomplete | The digest has complete trust metadata, but a registry failure may have applied only a prefix of the planned tag set. GitHub Release remains draft. | Rerun only failed jobs. Finalize reads fresh registry state, accepts candidate tags already applied, and applies the remaining eligible tags. Investigate any reported drift instead of replaying a saved prepare result. | -| Complete, package private | Authenticated pulls work and planned tags resolve correctly. | Inspect, then perform the one-time public visibility change. | -| Complete, package public | Anonymous digest and tag pulls work. | Monitor and publish only additive corrective releases. | - -The publisher plans tags before its first upload, publishes and signs the OCI layout by digest through `publish oci prepare`, creates all three attestations, and applies public tags last through `publish oci finalize`. A failed-job rerun reuses the same authoritative artifact and may add duplicate valid signatures or attestations after a partial success. Finalize reads current registry state rather than replaying the earlier plan. The GitHub Release publisher requires the successful digest-pinned OCI output and cannot make the release public until the image publisher job succeeds. - -## GHCR visibility - -GHCR visibility is independent of source repository visibility. The organization's package-creation setting determines the initial visibility. The workflow links the package to its source repository through `GITHUB_TOKEN` publication and the `org.opencontainers.image.source` annotation; inherited repository access permissions do not make the package public. - -The completed delivery state is public. Inspect `visibility` through the Packages REST API after the first complete publication. If it is private, an organization owner must inspect the signed and attested image, then make the package public through its settings page. GitHub does not expose a supported Packages REST operation for this visibility change. - -## Security boundary - -The current boundary is deliberately split: - -- `go-pre-publish.yml` runs `release-cli stage --profile go` to compile, sign, validate, and project release inputs without release or package write access; -- `go-oci-build.yml` packages and composes the image without registry credentials; -- `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 uses `release-cli` for pre-download artifact metadata verification, digest-addressed publication, signing, fresh-state tag planning, serial tag application, and postcondition verification. The builder uses `release-cli image build` to verify the projected canonical binary digests and compose the OCI layout, then uses `release-cli image verify` to check the layout, runtime invariants, architecture SBOMs, and index digest. The three SHA-pinned `actions/attest` steps create trust metadata between prepare and finalize. - -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. - -See [Release trust boundaries](../explanation/release-trust-boundaries.md) for the reasons behind the workflow, setup-action, and CLI responsibility split. - -## Unsupported cases - -The current contract does not support: - -- CGO-dependent or dynamically linked commands; -- architectures other than Linux amd64 and arm64; -- prerelease tags; -- multiple commands or entrypoints in one image; -- custom registries or image names; -- private package visibility automation; -- long-lived registry credentials; -- mutable exact-version tags; or -- publication from branch or manual-dispatch refs. diff --git a/docs/reference/package-repository-contract.md b/docs/reference/package-repository-contract.md deleted file mode 100644 index e7e6f97..0000000 --- a/docs/reference/package-repository-contract.md +++ /dev/null @@ -1,275 +0,0 @@ -# Package repository contract - -This reference defines the static package repository accepted and published by `release-cli publish package-repository` and `.github/workflows/publish-package-repository.yml`. - -For deployment steps, see [Set up the shared package repository](../how-to/set-up-package-repository.md). - -## Supported repository - -The current contract supports one channel, two architectures, and three package formats. - -| Domain | Accepted values | -|---|---| -| Channel | `stable` | -| Architectures | `amd64`, `arm64` | -| Package formats | DEB, RPM, APK | -| Public origin | Absolute HTTPS origin without a path prefix, query, fragment, or credentials | -| Object storage | One existing Cloudflare R2 bucket through its S3-compatible API | - -Each producer owns an allowlisted set of package names. A package name can belong to only one producer. Every requested release must contain exactly one package for each configured package name, format, and architecture. - -The publisher does not create the R2 bucket, custom domain, signing keys, GitHub environment, producer release, or central repository. - -## Producer release contract - -A publication request contains two public values: - -- `repository`: lowercase GitHub `owner/name`; -- `tag`: exact stable `vMAJOR.MINOR.PATCH` tag. - -The producer must have a published GitHub Release for the tag. The tag must resolve to one full lowercase commit SHA. The release download is a closed set: every downloaded file must be either listed in `checksums.txt` or be one of these controls: - -- `checksums.txt`; -- `checksums.txt.sigstore.json`. - -`checksums.txt` cannot list either control file. Every listed file must match its SHA-256 value. No extra file, directory, symlink, or irregular entry is accepted. - -For each DEB, RPM, and APK entry, the publisher also requires: - -1. a matching GitHub Release asset whose GitHub digest equals the checksum digest; -2. a GitHub build-provenance attestation for the exact producer repository, `refs/tags/`, resolved source commit, and configured attestation workflow; -3. package metadata whose name, version, and architecture match the reviewed producer policy and requested release; -4. a valid producer-native signature for RPM and APK packages. - -The detached Sigstore bundle for `checksums.txt` must match this certificate identity: - -```text -https://github.com//@refs/tags/ -``` - -Its OIDC issuer must be `https://token.actions.githubusercontent.com`. - -## Policy file - -`--config` points to one strict YAML document. The parser rejects unknown fields, aliases, multiple documents, duplicate producers, duplicate package ownership, duplicate published key names, unsupported channels, malformed paths, and files larger than 64 KiB. - -```yaml -channel: stable -origin: https://pkgs.example.com -keys: - apt: - source: keys/repository.asc - published: apt-repository-001.asc - rpm: - source: keys/repository.asc - published: rpm-repository-001.asc - apk: - source: keys/repository-apk.rsa.pub - published: apk-index-001.rsa.pub -producers: - - repository: owner/project - packages: - - project - checksum_workflow: .github/workflows/go-pre-publish.yml - attestation_workflow: .github/workflows/publish-github-release.yml - rpm_key: - source: keys/project-rpm.asc - published: project-rpm-001.asc - apk_key: - source: keys/project-apk.rsa.pub - published: project-apk-001.rsa.pub -``` - -### Top-level fields - -| Field | Contract | -|---|---| -| `channel` | Must be `stable`. | -| `origin` | Public HTTPS repository root used for post-upload installation. | -| `keys.apt` | Aggregate OpenPGP public key used by APT clients. | -| `keys.rpm` | Aggregate OpenPGP public key used to verify RPM metadata. It may share a `source` file with `keys.apt`. | -| `keys.apk` | Aggregate RSA public key used to verify APK indexes. | -| `producers` | Non-empty allowlist of producer repositories and package ownership. | - -Each key has these fields: - -| Field | Contract | -|---|---| -| `source` | Slash-separated path beneath `--keys`; no absolute path or traversal. | -| `published` | Flat public filename written beneath `keys/`; unique across all aggregate and producer keys. | - -### Producer fields - -| Field | Contract | -|---|---| -| `repository` | Lowercase GitHub `owner/name`; unique. | -| `packages` | Non-empty package-name allowlist; ownership is unique across producers. | -| `checksum_workflow` | Repository-relative `.github/workflows/*.yml` or `.yaml` path. | -| `attestation_workflow` | Repository-relative `.github/workflows/*.yml` or `.yaml` path. | -| `rpm_key` | Producer RPM package-signing public key. | -| `apk_key` | Producer APK package-signing public key. | - -## Public object layout - -The publisher regenerates the complete repository from the verified incoming release plus every existing immutable package object in R2. - -```text -keys/ - -apt/ - pool/main///__.deb - dists/stable/InRelease - dists/stable/main/binary-/Packages - dists/stable/main/binary-/Packages.gz - dists/stable/main/binary-/by-hash/SHA256/ -rpm/ - stable//Packages/--1..rpm - stable//repodata/repomd.xml - stable//repodata/repomd.xml.asc - stable//repodata/* -apk/ - stable/main//-.apk - stable/main//APKINDEX.tar.gz -``` - -Architecture names are format-specific: - -| Normalized | APT | RPM | APK | -|---|---|---|---| -| `amd64` | `amd64` | `x86_64` | `x86_64` | -| `arm64` | `arm64` | `aarch64` | `aarch64` | - -APT `InRelease` is clear-signed with the aggregate OpenPGP key. RPM `repomd.xml.asc` is an armored detached signature of `repomd.xml`. Each `APKINDEX.tar.gz` carries the aggregate APK RSA signature. - -Native clients use both trust layers. APT trusts the aggregate metadata key. -DNF trusts the aggregate RPM metadata key and every configured producer RPM -package key. APK trusts the aggregate index key and every configured producer -APK package key. Omitting a producer key must fail the installation acceptance -check even when the repository index signature is valid. - -APT metadata uses the producer GitHub Release publication time as its deterministic creation time. `Valid-Until` is 365 days after that timestamp. - -## Publication transaction - -Publication has this order: - -1. copy reviewed public keys into confined scratch storage; -2. download and close-set verify the producer release; -3. verify package attestations and native signatures; -4. list and stream every existing immutable package object from R2; -5. regenerate APT, RPM, and APK metadata locally; -6. install the exact package version from the local tree with APT, DNF, and APK; -7. upload all missing or changed non-root objects; -8. upload mutable commit roots last; -9. install the exact package version from the public origin with APT, DNF, and APK. - -The commit roots are: - -- `apt/dists/stable/InRelease`; -- one `rpm/stable//repodata/repomd.xml` per architecture; -- one `apk/stable/main//APKINDEX.tar.gz` per architecture. - -All other generated objects are uploaded before any commit root. A process crash can leave unreferenced inner objects, but it does not activate an incomplete repository view before the relevant root is uploaded. - -The publisher has no distributed lock. The reusable workflow uses one `package-repository-production` concurrency group with `cancel-in-progress: false`. All production writes must pass through that workflow. - -## Object state and caching - -Every uploaded object stores its canonical `sha256:` digest in R2 user metadata. The publisher compares the digest and size before writing. - -| Object class | Mutation | `Cache-Control` | -|---|---|---| -| Packages, public keys, APT by-hash objects | Immutable | `public, max-age=31536000, immutable` | -| Indexes, signatures, other replaceable metadata | Replaceable | `no-store` | - -A matching object is skipped. A digest or size mismatch at an immutable path is a hard failure. Replaceable metadata may be overwritten. Publication does not delete or prune objects. - -## Installation acceptance check - -The publisher runs three pinned Linux containers: - -- Debian for APT; -- Fedora for DNF; -- Alpine for APK. - -The local pass mounts the generated tree and public keys read-only with networking disabled. The public pass uses the configured HTTPS origin. Each client installs every package owned by the producer and verifies the exact requested version. - -A publication is successful only when both passes succeed. - -## Command interface - -```text -release-cli publish package-repository [flags] -``` - -Flags override their corresponding environment variables. - -| Flag | Environment | Required value | -|---|---|---| -| `--repository` | `RELEASE_REPOSITORY` | Producer `owner/name` | -| `--tag` | `RELEASE_TAG` | Stable release tag | -| `--config` | `RELEASE_PACKAGE_REPOSITORY_CONFIG` | Policy YAML path | -| `--keys` | `RELEASE_PACKAGE_KEYS` | Public-key source directory | -| `--cloudflare-account-id` | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID | -| `--r2-bucket` | `RELEASE_R2_BUCKET` | Existing R2 bucket | -| `--gpg-home` | `RELEASE_GPG_HOME` | GnuPG home containing the aggregate secret key | -| `--gpg-key-id` | `RELEASE_GPG_KEY_ID` | Full aggregate OpenPGP key fingerprint | -| `--gpg-passphrase-file` | `RELEASE_GPG_PASSPHRASE_FILE` | Owner-only passphrase file | -| `--apk-signing-key` | `RELEASE_APK_SIGNING_KEY` | Aggregate APK RSA private key file | - -These environment variables have no flag: - -| Environment | Contract | -|---|---| -| `R2_ACCESS_KEY_ID` | R2 S3 access key ID | -| `R2_SECRET_ACCESS_KEY` | R2 S3 secret access key | -| `GITHUB_TOKEN` or `GH_TOKEN` | GitHub release and attestation read token; `GITHUB_TOKEN` wins | -| `GITHUB_API_URL` | Optional GitHub API base | -| `GITHUB_SERVER_URL` | Optional GitHub server base | -| `RELEASE_GH_PATH` | Optional `gh` executable override | -| `RELEASE_DOCKER_PATH` | Optional Docker executable override | -| `RELEASE_COSIGN_PATH` | Optional Cosign executable override | -| `RELEASE_GPG_PATH` | Optional GnuPG executable override | - -The command accepts no positional arguments. - -With `--json`, success writes one `release.dev/result/v1` envelope whose `result` has this shape: - -```json -{ - "state": "published", - "repository": "owner/project", - "tag": "v1.2.3", - "artifacts": 26, - "uploaded": 26 -} -``` - -`state` is `published` when at least one object was uploaded and `unchanged` when every generated object already matched. `artifacts` depends on the configured package set and generated metadata; `26` is illustrative. - -Usage and configuration failures exit with code `2`. Verification, generation, installation, storage, and publication failures exit with code `1`. - -## Reusable workflow interface - -`.github/workflows/publish-package-repository.yml` accepts these `workflow_call` inputs: - -| Input | Type | Default | Required | -|---|---|---|---| -| `repository` | string | — | yes | -| `tag` | string | — | yes | -| `config-path` | string | `.config/package-repository.yaml` | no | -| `keys-path` | string | `.config` | no | -| `cloudflare-account-id` | string | — | yes | -| `r2-bucket` | string | — | yes | - -The job runs on `ubuntu-24.04`, has a 45-minute timeout, selects the `packages-production` environment, and requests only `contents: read` and `attestations: read` permissions. - -The selected environment must define: - -- `R2_ACCESS_KEY_ID`; -- `R2_SECRET_ACCESS_KEY`; -- `PACKAGE_REPOSITORY_GPG_PRIVATE_KEY`; -- `PACKAGE_REPOSITORY_GPG_PASSPHRASE`; -- `PACKAGE_REPOSITORY_APK_PRIVATE_KEY`. - -The workflow checks out the caller repository for its policy and public keys. It builds `release-cli` from the exact reusable-workflow source revision, verifies the producer assets, materializes the aggregate keys on the ephemeral runner, and invokes one CLI publication command. diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md deleted file mode 100644 index 5f3471a..0000000 --- a/docs/reference/release-cli-contract.md +++ /dev/null @@ -1,1135 +0,0 @@ -# `release-cli` contract reference - -`release-cli` builds and validates Go release data, reports machine-readable results, builds and verifies OCI layouts from staged binaries, initializes cask-only Homebrew taps, opens protected tap and Scoop bucket pull requests, publishes verified GitHub Releases, performs two-phase digest-addressed OCI publication, and converges verified DEB, RPM, and APK repository trees in Cloudflare R2. The [GitHub Release contract](github-release-contract.md) defines the workflow inputs, artifacts, and publication behavior that surround the CLI. The [package repository contract](package-repository-contract.md) defines native package ingestion and repository publication. - -## Commands - -| Command | Purpose | -| --- | --- | -| `release-cli stage --profile go --dist PATH [--json]` | Build and validate a Go release bundle under `PATH`, then write its OCI input projection. | -| `release-cli image build [--input DIR] [--work DIR] [--output DIR] [--melange-config PATH] [--apko-config PATH] [--build-date RFC3339] [--version VERSION] [--json]` | Build a locked multi-architecture OCI layout from staged Linux binaries. | -| `release-cli image verify [--output DIR] [--work DIR] [--binary NAME] [--version VERSION] [--json]` | Verify the built OCI layout, runtime contract, and architecture SBOMs. | -| `release-cli plan tags [--image IMAGE] [--version VERSION] --digest DIGEST [--plain-http] [--json]` | Inspect the immutable exact tag and moving channel tags for an OCI release. | -| `release-cli publish oci prepare --layout PATH [--image IMAGE] [--version VERSION] --digest DIGEST [--dry-run] [--plain-http] [--json]` | Validate and prepare a digest-addressed OCI image publication and recursive signature. | -| `release-cli publish oci finalize --result - [--plain-http] [--json]` | Re-read registry state and apply verified OCI image tags after attestation. | -| `release-cli publish github --dist PATH [--no-undraft] [--json]` | Reconcile a verified bundle with its matching GitHub Release and optionally publish the draft. | -| `release-cli publish package-repository --repository OWNER/NAME --tag TAG --config PATH --keys DIR --cloudflare-account-id ID --r2-bucket BUCKET --gpg-home DIR --gpg-key-id FINGERPRINT --gpg-passphrase-file PATH --apk-signing-key PATH [--json]` | Verify one producer release, regenerate the complete static package repository, install locally, reconcile R2, and install from the public origin. | -| `release-cli init homebrew-tap --tap OWNER/HOMEBREW-NAME --output DIR [--json]` | Write a cask-only tap scaffold into a new or empty local directory. | -| `release-cli init scoop-bucket --bucket OWNER/REPOSITORY --output DIR [--json]` | Write a root-layout Scoop bucket scaffold into a new or empty local directory. | -| `release-cli publish homebrew --dist PATH --tap OWNER/REPOSITORY --cask TOKEN [--json]` | Reconcile a generated cask through a protected Homebrew tap pull request. | -| `release-cli publish scoop --dist PATH --bucket OWNER/REPOSITORY --manifest NAME [--json]` | Reconcile a generated Scoop manifest through a protected bucket pull request. | -| `release-cli verify bundle --dist PATH --identity URL [--issuer URL] [--json]` | Verify a closed release bundle and its detached Sigstore signature. | -| `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. | - -`stage`, `verify bundle`, `publish github`, `publish homebrew`, and `publish scoop` require a distribution path. `init homebrew-tap` requires `--tap` and `--output`; the repository name must use `homebrew-`. `init scoop-bucket` requires `--bucket` in `owner/repository` form and `--output`. Both initializers require a released CLI whose build metadata contains a full source commit. The only accepted profile is `go`. `verify bundle` also requires an exact certificate identity. `verify handoff` requires artifact ID and digest values. Supply handoff values with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable. - -Boolean `RELEASE_*` environment variables must contain a value accepted by Go's `strconv.ParseBool`: `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, or `False`. Any other value is invalid configuration and exits with code `2`. - -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 - -When option and argument parsing succeeds and `--json` is requested, stdout contains exactly one JSON document and no other output. The envelope has this structure: - -```text -{"schema":"release.dev/result/v1","command":"","ok":,"result":{...}} -``` - -| Field | Value | -| --- | --- | -| `schema` | Always `release.dev/result/v1`. | -| `command` | The command path, such as `image build`, `image verify`, `init homebrew-tap`, `init scoop-bucket`, `plan tags`, `publish github`, `publish homebrew`, `publish scoop`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, `verify handoff`, or `version`. | -| `ok` | `true` when the command succeeds; otherwise `false`. | -| `result` | The command-specific result object. | - -The `stage --json` result contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `assets` | number | Number of payloads whose checksums matched. | -| `binaries` | object | Entries named `amd64` and `arm64` for the verified Linux binaries. | -| `binaries..path` | string | Original `/`-prefixed path from `artifacts.json`. | -| `binaries..mode` | string | Observed permission bits in octal notation. | - -For `init homebrew-tap --json`, `command` is exactly `init homebrew-tap`. The `result` object contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `tap` | string | Validated target repository in `owner/homebrew-name` form. | -| `output` | string | Clean local output path. | -| `files` | array of strings | Generated slash-separated paths in lexical order. | - -For `init scoop-bucket --json`, `command` is exactly `init scoop-bucket`. The `result` object contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `bucket` | string | Validated target repository in `owner/repository` form. | -| `output` | string | Clean local output path. | -| `files` | array of strings | Generated slash-separated paths in lexical order. | - -For `image build --json`, `command` is exactly `image build`. The `result` object contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `schema` | string | Always `release.dev/image-build/v1`. | -| `version` | string | Stable release version used for the APKs and image annotations. | -| `binary` | string | Shared filename of the two canonical Linux binaries. | -| `work` | string | Scratch workspace selected by `--work` or `RELEASE_WORK`. | -| `output` | string | Authoritative artifact output root selected by `--output` or `RELEASE_OUTPUT`. | -| `build_date` | string | Reproducible build time in RFC 3339 format. | -| `packages` | array of objects | The two APKs, ordered as `linux/amd64` and then `linux/arm64`. | -| `packages[].platform` | string | Canonical Linux platform: `linux/amd64` or `linux/arm64`. | -| `packages[].arch` | string | Corresponding APK architecture: `x86_64` or `aarch64`. | -| `packages[].package` | string | Output-root-relative path to the architecture's only APK. | -| `packages[].binary_digest` | string | Verified canonical binary digest with the `sha256:` prefix. | - -For example, a successful build writes this envelope: - -```json -{ - "schema": "release.dev/result/v1", - "command": "image build", - "ok": true, - "result": { - "schema": "release.dev/image-build/v1", - "version": "1.2.3", - "binary": "release-cli", - "work": "/tmp/oci-build", - "output": "/tmp/oci-output", - "build_date": "2026-08-19T15:04:05Z", - "packages": [ - { - "platform": "linux/amd64", - "arch": "x86_64", - "package": "packages/x86_64/release-cli-1.2.3-r0.apk", - "binary_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - { - "platform": "linux/arm64", - "arch": "aarch64", - "package": "packages/aarch64/release-cli-1.2.3-r0.apk", - "binary_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - } - ] - } -} -``` - -For `image verify --json`, `command` is exactly `image verify`. The `result` object contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `schema` | string | Always `release.dev/image-verify/v1`. | -| `version` | string | Stable release version expected in the image and architecture SBOMs. | -| `binary` | string | Name of the canonical binary installed in the image. | -| `index_digest` | string | SHA-256 digest of the exact `index.json` bytes, with the `sha256:` prefix. | -| `platforms` | array of objects | Verified platforms in canonical order: `linux/amd64`, then `linux/arm64`. | -| `platforms[].platform` | string | Canonical Linux platform: `linux/amd64` or `linux/arm64`. | -| `platforms[].arch` | string | Corresponding APK architecture: `x86_64` or `aarch64`. | -| `platforms[].manifest` | string | Platform manifest digest with the `sha256:` prefix. | -| `platforms[].config` | string | Platform config digest with the `sha256:` prefix. | -| `platforms[].layer` | string | Platform layer digest with the `sha256:` prefix. | -| `platforms[].binary_digest` | string | SHA-256 digest of the installed binary, with the `sha256:` prefix. | - -For example, successful verification writes this envelope: - -```json -{ - "schema": "release.dev/result/v1", - "command": "image verify", - "ok": true, - "result": { - "schema": "release.dev/image-verify/v1", - "version": "1.2.3", - "binary": "release-cli", - "index_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "platforms": [ - { - "platform": "linux/amd64", - "arch": "x86_64", - "manifest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "config": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "layer": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "binary_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - }, - { - "platform": "linux/arm64", - "arch": "aarch64", - "manifest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - "config": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "layer": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "binary_digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333" - } - ] - } -} -``` - -The `verify bundle --json` result contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `dist` | string | Distribution directory selected by `--dist` or `RELEASE_DIST`. | -| `identity` | string | Exact certificate identity URL used for Sigstore verification. | -| `issuer` | string | OIDC issuer used for Sigstore verification. | -| `payloads` | array of objects | Checksummed release payloads in `checksums.txt` order. | -| `payloads[].name` | string | Flat payload name inside the distribution directory. | -| `payloads[].digest` | string | Payload SHA-256 digest as 64 lowercase hexadecimal characters without a `sha256:` prefix. | -| `controls` | array of objects | The two control files, ordered as `checksums.txt` and `checksums.txt.sigstore.json`. | -| `controls[].name` | string | Control file name inside the distribution directory. | -| `controls[].digest` | string | Control file SHA-256 digest as 64 lowercase hexadecimal characters without a `sha256:` prefix. | - -For example, a verified bundle produces this result object: - -```json -{ - "dist": "dist", - "identity": "https://github.com/owner/repo/.github/workflows/go-pre-publish.yml@refs/heads/main", - "issuer": "https://token.actions.githubusercontent.com", - "payloads": [ - { - "name": "release-cli_1.2.3_linux_amd64.tar.gz", - "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - } - ], - "controls": [ - { - "name": "checksums.txt", - "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - { - "name": "checksums.txt.sigstore.json", - "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - } - ] -} -``` - -For `publish github --json`, `command` is exactly `publish github`. The `result` object contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `release_id` | number | Positive GitHub Release ID. | -| `tag` | string | Exact tag carried by the release. | -| `url` | string | GitHub HTML URL for the release. | -| `draft` | boolean | Final observed draft state. `false` means the release is public. | -| `assets` | array of strings | Converged asset names, sorted lexicographically. | - -For example, a successful publication writes this envelope: - -```json -{ - "schema": "release.dev/result/v1", - "command": "publish github", - "ok": true, - "result": { - "release_id": 123456, - "tag": "v1.2.3", - "url": "https://github.com/owner/repo/releases/tag/v1.2.3", - "draft": false, - "assets": [ - "checksums.txt", - "checksums.txt.sigstore.json", - "example_1.2.3_linux_amd64.tar.gz" - ] - } -} -``` - -For `publish homebrew --json`, `command` is exactly `publish homebrew`. The `result` object contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `tap` | string | Target tap in `owner/repository` form. | -| `cask` | string | Published cask token. | -| `branch` | string | Deterministic publication branch in `release//v` form. | -| `pull_request_url` | string | Matching pull request URL. This can be empty when matching cask content reached the default branch without a discoverable pull request. | -| `state` | string | `created` when the command opened the pull request, `open` when it accepted an existing pull request, or `published` when matching content is on the default branch. | - -For example, a new tap publication writes this envelope: - -```json -{ - "schema": "release.dev/result/v1", - "command": "publish homebrew", - "ok": true, - "result": { - "tap": "owner/homebrew-tap", - "cask": "example", - "branch": "release/example/v1.2.3", - "pull_request_url": "https://github.com/owner/homebrew-tap/pull/42", - "state": "created" - } -} -``` - -For `publish scoop --json`, `command` is exactly `publish scoop`. The `result` object contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `bucket` | string | Target bucket in `owner/repository` form. | -| `manifest` | string | Published manifest name. | -| `branch` | string | Deterministic publication branch in `release//v` form. | -| `pull_request_url` | string | Matching pull request URL. This can be empty when matching manifest content reached the default branch without a discoverable pull request. | -| `state` | string | `created` when the command opened the pull request, `open` when it accepted an existing pull request, or `published` when matching content is on the default branch. | - -For example, a new bucket publication writes this envelope: - -```json -{ - "schema": "release.dev/result/v1", - "command": "publish scoop", - "ok": true, - "result": { - "bucket": "owner/scoop-bucket", - "manifest": "example", - "branch": "release/example/v1.2.3", - "pull_request_url": "https://github.com/owner/scoop-bucket/pull/42", - "state": "created" - } -} -``` - -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"} - ] -} -``` - -For `publish oci prepare --json`, `command` is exactly `publish oci prepare`. The `result` object has schema `release.dev/oci-prepare/v1` and contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `schema` | string | Always `release.dev/oci-prepare/v1`. | -| `authoritative` | boolean | `true` after a non-dry-run preparation completes; `false` for `--dry-run`. A non-authoritative result is not usable for publication. | -| `image` | string | OCI image name prepared by the command. | -| `version` | string | Candidate stable release version. | -| `index_digest` | string | OCI index digest, normalized to lowercase with the `sha256:` prefix. | -| `platforms` | array of objects | Platform manifests in the order recorded by `index.json`. | -| `platforms[].platform` | string | Platform in `OS/architecture` form, such as `linux/amd64`. | -| `platforms[].digest` | string | Digest of the platform manifest. | -| `observed` | array of objects | Registry observations ordered by scope: exact, minor, major, then latest. | -| `observed[].tag` | string | Exact or channel tag that was observed. | -| `observed[].scope` | string | Tag scope: `exact`, `minor`, `major`, or `latest`. | -| `observed[].present` | boolean | Whether the tag was present in the registry. | -| `observed[].digest` | string | Digest resolved from a present tag. This field is omitted for an absent tag. | -| `observed[].version` | string | Stable version read from the current manifest annotation. This field is omitted when no annotation was read. | - -For example, a successful non-dry-run preparation writes this standard envelope: - -```json -{ - "schema": "release.dev/result/v1", - "command": "publish oci prepare", - "ok": true, - "result": { - "schema": "release.dev/oci-prepare/v1", - "authoritative": true, - "image": "ghcr.io/owner/repo", - "version": "1.2.3", - "index_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "platforms": [ - { - "platform": "linux/amd64", - "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - { - "platform": "linux/arm64", - "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - } - ], - "observed": [ - {"tag": "1.2.3", "scope": "exact", "present": false}, - {"tag": "1.2", "scope": "minor", "present": false}, - { - "tag": "1", - "scope": "major", - "present": true, - "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "version": "1.1.9" - }, - { - "tag": "latest", - "scope": "latest", - "present": true, - "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - } - ] - } -} -``` - -For `publish oci finalize --json`, `command` is exactly `publish oci finalize`. The `result` object has schema `release.dev/oci-finalize/v1` and contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `schema` | string | Always `release.dev/oci-finalize/v1`. | -| `image` | string | OCI image name from the prepare result. | -| `version` | string | Candidate stable release version from the prepare result. | -| `index_digest` | string | Candidate OCI index digest from the prepare result. | -| `applied` | array of strings | Tags written by this run, in tag-plan order. | -| `accepted` | array of strings | Tags that already resolved to the candidate index digest. | -| `retained` | array of strings | Channel tags left on a newer release. | - -For example, this result applies the exact and minor tags while retaining newer major and `latest` channels: - -```json -{ - "schema": "release.dev/result/v1", - "command": "publish oci finalize", - "ok": true, - "result": { - "schema": "release.dev/oci-finalize/v1", - "image": "ghcr.io/owner/repo", - "version": "1.2.3", - "index_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "applied": ["1.2.3", "1.2"], - "accepted": [], - "retained": ["1", "latest"] - } -} -``` - -The `version --json` result contains exactly these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `version` | string | Release version stamped into the binary. | -| `commit` | string | Source commit stamped into the binary. | -| `protocol` | number | Protocol integer compiled into the CLI. The current value is `1`. | - -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 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 `image build`, `image verify`, `plan tags`, `publish github`, `publish homebrew`, `publish scoop`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, 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 - -| Code | Meaning | -| ---: | --- | -| `0` | The command completed successfully. | -| `1` | A tool invocation, release contract, or verification check failed. The command fails closed. | -| `2` | Command usage or configuration is invalid. This includes an unsupported `--profile` value. | - -No other exit code is defined; in particular, code `3` has no meaning. An exit code does not make a general promise that a command is safe to run again. - -## OCI image build - -`release-cli image build` turns the canonical Linux binaries recorded by `stage --profile go` into signed APK repositories and a locked multi-architecture OCI layout. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Input artifact root | `--input` | `RELEASE_INPUT` | None. A path is required. | -| Scratch workspace | `--work` | `RELEASE_WORK` | None. A path is required. | -| Authoritative output root | `--output` | `RELEASE_OUTPUT` | None. A path is required. | -| Melange configuration | `--melange-config` | `RELEASE_MELANGE_CONFIG` | `melange.yaml`. | -| apko configuration | `--apko-config` | `RELEASE_APKO_CONFIG` | `apko.yaml`. | -| Build date | `--build-date` | `RELEASE_BUILD_DATE` | None. An RFC 3339 value is required. | -| Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. | -| Melange binary | None. | `RELEASE_MELANGE_PATH` | Resolve `melange` from `PATH` when invoked. | -| apko binary | None. | `RELEASE_APKO_PATH` | Resolve `apko` from `PATH` when invoked. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -An explicitly set flag takes precedence over its environment variable. The version default applies only when `--version` and `RELEASE_VERSION` are absent. The command reads `oci-build-inputs.json` from the input artifact root. - -The command reads this GitHub Actions context: - -| Variable | Value | -| --- | --- | -| `GITHUB_REPOSITORY` | Repository in `owner/name` form. The name after the slash forms the local image reference `local/:`. | -| `GITHUB_REPOSITORY_OWNER` | APK package namespace. | -| `GITHUB_SERVER_URL` | Absolute GitHub server URL. Combined with `GITHUB_REPOSITORY` for the provenance source URL. | -| `GITHUB_SHA` | Provenance commit SHA and OCI revision annotation. | -| `GITHUB_REF_NAME` | Version source when neither the flag nor `RELEASE_VERSION` is set. | - -`--work` and `--output` must be disjoint. Equality is invalid, as is either path containing the other. The command exits with code `2` for any of these relationships. This separation keeps the ephemeral Melange signing key in the work directory and out of the output directory that becomes the authoritative uploaded artifact. - -The command performs these operations in order: - -1. Resolve and validate all flags, environment variables, and Actions context, including the work and output path relationship. Every configuration failure exits with code `2` before the command creates a directory or invokes a tool. -2. Open the input artifact root, then decode and validate `oci-build-inputs.json`. A missing or malformed projection exits with code `1` without creating the work or output directory. -3. Open the selected Melange and apko configuration files. An unopenable configuration file exits with code `1` without creating the work or output directory or invoking a tool. -4. Construct the Melange and apko ports. Construction does not invoke either tool. -5. Create the work and output roots when absent, then open them. Each root must be empty. The command refuses any pre-existing entry in either directory, so unrelated files cannot enter the build or the authoritative artifact. -6. Create new source directories under the work root and new `configuration`, `packages`, `layout`, and `sboms` directories under the output root. -7. Process `linux/amd64` and then `linux/arm64`. For each platform, stream the projected binary into `sources//application` while computing its SHA-256 digest, set mode `0755`, compare the computed digest with the projection, and inspect the copied executable. -8. Write `vars.json` with the stable version and a trailing newline. -9. Copy the selected Melange and apko configurations to `configuration/melange.yaml` and `configuration/apko.yaml` with mode `0644`. -10. Write `canonical-binaries.sha256` in GNU coreutils form, with `x86_64` first and `aarch64` second. -11. Use Melange to compile-check `x86_64`, generate an ephemeral signing key, and build the `x86_64` and `aarch64` APK repositories in that order. -12. Require the builder's repository and public-key paths to match the requested paths, then inspect both APK repositories. -13. Copy the generated public key to `apk-signing.rsa.pub` with mode `0644`. The private key remains in the scratch workspace. -14. Use apko to write `apko.lock.json`, then compose the two-architecture layout and SBOMs with version and revision annotations. -15. Require the lockfile, OCI layout marker and index, and both architecture SBOMs to be nonempty regular files. -16. Return the build result, including both verified binary digests and APK paths. - -The build checks these boundaries: - -| Check | Requirement | -| --- | --- | -| Canonical binary digest | The SHA-256 digest computed from each downloaded binary must equal its digest in `oci-build-inputs.json`. | -| Executable format | Each binary must be a statically linked, 64-bit, little-endian ELF executable with no interpreter or needed dynamic libraries. The `x86_64` source must have the x86-64 machine type; the `aarch64` source must have the AArch64 machine type. | -| APK repository | Each architecture directory must contain a nonempty `APKINDEX.tar.gz` and exactly one nonempty `.apk` file. | -| Composer outputs | `apko.lock.json`, `layout/index.json`, `layout/oci-layout`, `sboms/sbom-x86_64.spdx.json`, and `sboms/sbom-aarch64.spdx.json` must be nonempty regular files. | - -`image build` checks that the layout files and SBOMs exist but does not deeply verify their contents. `image verify` independently checks the layout structure, runtime invariants, architecture SBOMs, and digest of the exact `layout/index.json` bytes. - -| Exit code | Meaning | -| ---: | --- | -| `0` | The image build completed successfully. | -| `1` | The input projection is missing or malformed; a Melange or apko configuration file cannot be opened; or a staged-content contract, executable, tool invocation, APK repository, or composer-output check failed. | -| `2` | Command usage or configuration is invalid. | - -## OCI image verification - -`release-cli image verify` verifies the layout and SBOMs produced by `image build` against the canonical binaries in the scratch workspace. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Authoritative output root | `--output` | `RELEASE_OUTPUT` | None. A path is required. | -| Scratch workspace | `--work` | `RELEASE_WORK` | None. A path is required. | -| Binary name | `--binary` | `RELEASE_BINARY` | None. A name is required. | -| Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -An explicitly set flag takes precedence over its environment variable. The version default applies only when `--version` and `RELEASE_VERSION` are absent. The command validates all configuration before opening either root. Unlike `image build`, `image verify` does not require the work and output roots to be disjoint because it does not write into the work root. - -The command reads this GitHub Actions context: - -| Variable | Value | -| --- | --- | -| `GITHUB_SHA` | Expected OCI revision annotation. | -| `GITHUB_SERVER_URL` | Absolute GitHub server URL. | -| `GITHUB_REPOSITORY` | Repository in `owner/name` form. The expected source annotation is `/`. | -| `GITHUB_REF_NAME` | Version source when neither the flag nor `RELEASE_VERSION` is set. | - -The command enforces these checks: - -- `oci-layout` is a regular file. The OCI index is valid JSON, has schema version `2`, uses media type `application/vnd.oci.image.index.v1+json`, and contains exactly two manifests. Both manifests are for Linux, and their architecture set is exactly `amd64` and `arm64`. -- The index has `org.opencontainers.image.description`, `org.opencontainers.image.licenses`, `org.opencontainers.image.revision`, `org.opencontainers.image.source`, `org.opencontainers.image.title`, and `org.opencontainers.image.version` annotations. Description, licenses, and title are nonempty. Revision, source, and version equal the expected values. -- Each referenced platform manifest is a regular file of its declared size. It is valid JSON, has schema version `2`, uses media type `application/vnd.oci.image.manifest.v1+json`, contains exactly one layer, and repeats the six index annotation values. -- Each platform config has the descriptor's architecture and Linux as its operating system. Its labels repeat the six index annotation values. Its entrypoint is exactly `["/usr/bin/"]`, and its user is exactly `65532`. -- The layer media type is `application/vnd.oci.image.layer.v1.tar+gzip` or `application/vnd.oci.image.layer.v1.tar`. In its tar stream, `usr/bin/` appears exactly once as a regular file with mode exactly `0755`, with no setuid, setgid, or sticky bit set, and ownership exactly `0:0`. Its content is byte-identical to the corresponding `sources/x86_64/application` or `sources/aarch64/application` file in the scratch workspace. -- Each architecture SBOM contains an SPDX package whose `primaryPackagePurpose` is `APPLICATION` and whose `versionInfo` is `-r0`. - -The index digest is SHA-256 over the exact `layout/index.json` bytes. The command never computes this digest from re-marshaled JSON. After every check succeeds, it writes the digest followed by a newline to `image-digest.txt` in the output directory. - -| Exit code | Meaning | -| ---: | --- | -| `0` | The layout, runtime contract, and architecture SBOMs passed verification. | -| `1` | A layout, blob, runtime, canonical-binary, or SBOM verification check failed. | -| `2` | Command usage or configuration is invalid. | - -## 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//`, 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. | -| Plain HTTP | `--plain-http` | None. The option is flag-only. | Disabled. | -| 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. - -`--plain-http` permits an HTTP registry connection for local-registry testing only. The command refuses this flag unless the image host is `127.0.0.1`, `::1`, or `localhost`, optionally with a port. Any other host is invalid configuration and exits with code `2`. - -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 publisher workflow uses `publish oci prepare` and `publish oci finalize` for publication; it does not invoke the standalone `plan tags` inspection command. - -### 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 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. - - -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. - -## OCI digest preparation - -`release-cli publish oci prepare` validates and prepares one digest-addressed OCI layout for publication. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Layout directory | `--layout` | `RELEASE_LAYOUT` | None. A path is required. | -| Image | `--image` | `RELEASE_IMAGE` | `ghcr.io//`, lowercased from `GITHUB_REPOSITORY`. | -| Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. | -| Expected index digest | `--digest` | `RELEASE_DIGEST` | None. A digest is required. | -| Dry run | `--dry-run` | `RELEASE_DRY_RUN` | Disabled. | -| Plain HTTP | `--plain-http` | None. The option is flag-only. | Disabled. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -`--layout` identifies the extracted `oci-image/layout` directory. An explicitly set flag takes precedence over its environment variable. The derived image or version default applies only when the corresponding flag and release environment variable are absent. Image, version, and digest validation is the same as for `plan tags`. - -`--plain-http` permits an HTTP registry connection for local-registry testing only. The command refuses this flag unless the image host is `127.0.0.1`, `::1`, or `localhost`, optionally with a port. Any other host is invalid configuration and exits with code `2`. Never use plain HTTP for a real publication. - -Registry credentials use the same token and username resolution as `plan tags`. The command keeps these credentials in memory and does not write a Docker configuration file. - -The command performs these operations in order: - -1. Read and validate the OCI layout. -2. Compute the digest of the exact `index.json` bytes and require it to equal the expected `--digest`. -3. Collect fresh registry state and plan the exact and channel tags. An immutable exact-tag conflict stops the command before any registry write. -4. Push every unique config and layer blob, each platform manifest, and the index by digest. -5. Verify that the index and each platform manifest resolve by their expected digest. -6. Sign `image@` recursively with Cosign. - -The command never creates or moves a tag. - -With `--dry-run`, the command performs layout validation, digest verification, fresh registry-state collection, and tag planning only. It makes zero registry writes and does not invoke Cosign. The result has `"authoritative": false`; a non-authoritative result is not usable for publication. - -The command invokes a `cosign` binary resolved from `PATH`. Set `RELEASE_COSIGN_PATH` to override the binary path. Its signing invocation is: - -```text -cosign sign --yes --recursive @ -``` - -Keyless signing requires the ambient OIDC credentials supplied by the workflow. - -The reusable publisher workflow invokes this command before its three GitHub attestation steps. If all three attestations succeed, the workflow passes the command's JSON envelope to `publish oci finalize`. See [Why OCI publication has two phases](../explanation/two-phase-oci-publication.md) for the ordering rationale. - -## OCI tag finalization - -`release-cli publish oci finalize` accepts a successful prepare result, re-reads registry state, and applies the exact and eligible channel tags. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Prepare result | `--result` | None. | None. The flag is required and its only accepted value is `-`, which selects stdin. | -| Plain HTTP | `--plain-http` | None. The option is flag-only. | Disabled. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -Image, version, and index digest do not have finalize flags. The command reads all three values from the piped prepare result. Registry credentials and the loopback-only `--plain-http` restriction resolve exactly as they do for `publish oci prepare`. - -Stdin must contain exactly the JSON envelope emitted by a successful `publish oci prepare --json` command: - -```text -{"schema":"release.dev/result/v1","command":"publish oci prepare","ok":true,"result":{...}} -``` - -The nested `result` must be a valid `release.dev/oci-prepare/v1` document. An empty stdin stream, trailing content, a wrong envelope schema, a command other than `publish oci prepare`, or `ok` set to `false` is invalid configuration. A malformed nested prepare result is also invalid configuration. Any `--result` value other than `-` is a usage error; there is no receipt-file mode. All of these failures exit with code `2` before any registry request. - -The command refuses a dry-run prepare result because its `authoritative` field is `false`; this is a publication failure with exit code `1`. For an authoritative result, finalization performs these operations: - -1. Collect fresh state for the exact tag and the minor, major, and `latest` channels. -2. Compare fresh state with the observations in the prepare result. A tag that is unchanged or now resolves to the candidate index digest is accepted. Any other change is registry drift. -3. Recompute the tag plan from fresh state. -4. Commit the plan's apply tags serially, verifying each tag after it is written. The command skips the commit when the plan has no apply tags. -5. Independently resolve the exact tag and every applied tag and require each one to match the candidate index digest. - -Drift, an immutable exact-tag conflict, corrupt channel state, a registry failure, or a failed postcondition exits with code `1`. The commit and postcondition registry reads retry only retryable failures, with the same four-attempt and 1-second, 2-second, and 4-second wait pattern as preparation. A rerun can accept tags already applied to the candidate digest, but the command makes no general promise that arbitrary failures are safe to retry. - -## GitHub Release publication - -`release-cli publish github` reconciles the local closed bundle with the GitHub Release for the workflow tag. The default behavior publishes the draft after every asset check succeeds. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Distribution directory | `--dist` | `RELEASE_DIST` | None. A path is required. | -| Keep the release as a draft | `--no-undraft` | None. The option is flag-only. | Disabled. | -| GitHub CLI binary | None. | `RELEASE_GH_PATH` | Resolve `gh` from `PATH`. | -| Git binary | None. | `RELEASE_GIT_PATH` | Resolve `git` from `PATH`. | -| Release App installation token | None. | `RELEASE_APP_TOKEN` | None. A token is required. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -`--no-undraft` has no environment-variable form because it controls whether the release becomes public. With the flag, the command converges and verifies the assets, then verifies that the release remains a draft. Without the flag, the command publishes the draft only after asset convergence. - -The command requires this GitHub Actions context: - -| Variable | Value | -| --- | --- | -| `GITHUB_REPOSITORY` | Repository in `owner/name` form. | -| `GITHUB_REF_NAME` | Exact release tag. | -| `GITHUB_SHA` | Expected 40-character lowercase commit SHA for the workflow run. | -| `GITHUB_API_URL` | Optional absolute GitHub API base URL. The public GitHub API is the default. | -| `GITHUB_SERVER_URL` | Optional absolute GitHub server and upload base URL used with a custom API URL. | - -The workflow mints the short-lived Release App installation token and passes it through `RELEASE_APP_TOKEN`. The CLI holds the token as a redacted secret. It does not receive the App private key, mint an App token, or put the token in an argument or diagnostic. - -The command rebuilds the expected bundle from `--dist`: the payloads listed by `checksums.txt` plus `checksums.txt` and `checksums.txt.sigstore.json`, with their local SHA-256 digests. It repeats the closed-set and digest checks but does not repeat Sigstore verification. The publisher workflow runs `verify bundle` before attestation and invokes `publish github` only after attestation succeeds. - -Publication enforces these guarantees in order: - -1. Resolve the exact tag with Git and require it to equal `GITHUB_SHA`. -2. Poll GitHub for the tag and require it to identify exactly one release. Draft discovery makes 24 attempts, 5 seconds apart. Absence after this budget fails; the command never creates a release. -3. Require a draft before taking the mutation path. If `--no-undraft` was requested and the one matching release is already public, the state is indeterminate because the requested draft-only outcome was not preserved. Without `--no-undraft`, an already-public release is read without mutation: an exact match of count, names, uploaded states, and digests is a successful completed-publication rerun; any other state is indeterminate. The command never re-drafts a release. -4. Read the draft's assets and refuse any name outside the expected closed set. The command never deletes an unexpected asset. -5. Upload every expected local path with `gh release upload --clobber`. Clobber applies only to expected names that passed the closed-set check. -6. Poll asset state up to 12 times, 1 second apart. Success requires the expected count, unique expected names, `uploaded` state, and the exact GitHub-reported `sha256:` digest for every asset. -7. If `--no-undraft` is absent, change the release from draft to public. This is the last mutation. A failure from the undraft request is indeterminate because the update may have applied. If `--no-undraft` is present, do not change the draft state. -8. Read the release again and require its final draft state to match the requested outcome before returning the release URL and sorted asset names. A failed final read or an unexpected state after an undraft request is indeterminate. A public final state under `--no-undraft` is also indeterminate. - -A retryable operation uses at most four attempts, waiting 1 second, 2 seconds, and 4 seconds between attempts. Tag and commit mismatches, unexpected assets, and digest mismatches are not retryable. - -A missing distribution path (`--dist` or `RELEASE_DIST`), a missing `RELEASE_APP_TOKEN`, missing or malformed `GITHUB_REPOSITORY`, `GITHUB_REF_NAME`, or `GITHUB_SHA`, and malformed GitHub endpoint configuration are configuration errors. They exit with code `2` before any publication request. An unresolvable `RELEASE_GIT_PATH` or `RELEASE_GH_PATH` is reported when the selected binary is first invoked and exits with code `1`. The Git path is first used for tag resolution; the GitHub CLI path is first used for upload, after tag resolution, draft discovery, and the pre-upload asset read. Every other post-configuration tag-resolution, GitHub API, upload, convergence, or release-contract failure also exits with code `1`. Success exits with code `0`. No other exit code is defined. - -## Homebrew tap initialization - -`release-cli init homebrew-tap` writes a minimal cask-only tap into a local output directory. It performs no Git or GitHub operation. The output path must not exist or must be an empty directory. A file, symlink, or nonempty directory is rejected without changing its contents. - -The command writes exactly these files: - -| Path | Purpose | -| --- | --- | -| `.github/workflows/casks.yml` | Calls the reusable `homebrew-tap-ci.yml` workflow for pull requests that change `Casks/**`. | -| `.github/dependabot.yml` | Checks weekly for GitHub Actions updates. | -| `Casks/.gitkeep` | Keeps the empty cask directory in Git. | -| `README.md` | Records the tap and install syntax. | - -The reusable workflow reference is pinned to the full source commit stamped into the running `release-cli` binary. Development builds stamped with `none`, malformed commits, and abbreviated commits exit with code `2` before creating the output directory. The generated workflow grants `contents: read` to its only caller job and sets top-level permissions to an empty map. - -The command does not generate `Formula/`, a publisher workflow, repository settings, branch protection, secrets, or a GitHub App. Follow [Set up a Homebrew tap](../how-to/set-up-homebrew-tap.md) to create the repository and connect a producer. - -## Scoop bucket initialization - -`release-cli init scoop-bucket` writes a root-layout Scoop bucket into a local output directory. It performs no Git or GitHub operation. The output path must not exist or must be an empty directory. A file, symlink, or nonempty directory is rejected without changing its contents. - -The command writes exactly these files: - -| Path | Purpose | -| --- | --- | -| `.gitattributes` | Makes text files use CRLF in Windows checkouts for the pinned Scoop tests. | -| `.github/workflows/manifests.yml` | Calls the reusable `scoop-bucket-ci.yml` workflow for pull requests that change root `*.json` manifests. | -| `.github/dependabot.yml` | Checks weekly for GitHub Actions updates. | -| `README.md` | Records the bucket and install syntax. | - -The reusable workflow reference is pinned to the full source commit stamped into the running `release-cli` binary. Development builds stamped with `none`, malformed commits, and abbreviated commits exit with code `2` before creating the output directory. The generated workflow grants `contents: read` to its only caller job and sets top-level permissions to an empty map. - -The command does not generate a sample manifest, a publisher workflow, repository settings, branch protection, secrets, or a GitHub App. Follow [Set up a Scoop bucket](../how-to/set-up-scoop-bucket.md) to create the repository and connect a producer. - -## Homebrew cask publication - -`release-cli publish homebrew` reads the cask generated by GoReleaser and reconciles it through a tap pull request. The command never writes the tap's default branch, force-updates a branch, deletes a path, enables auto-merge, or merges the pull request. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Distribution directory | `--dist` | `RELEASE_DIST` | None. A path is required. | -| Target tap | `--tap` | None. | None. Use `owner/repository` form. | -| Cask token | `--cask` | None. | None. Use lowercase letters, digits, and interior hyphens. | -| Release App installation token | None. | `RELEASE_APP_TOKEN` | None. A token is required. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -The command requires this GitHub Actions context: - -| Variable | Value | -| --- | --- | -| `GITHUB_REPOSITORY` | Source repository in `owner/name` form. | -| `GITHUB_REF_NAME` | Stable release tag. | -| `GITHUB_SHA` | Expected 40-character lowercase commit SHA for the workflow run. | -| `GITHUB_API_URL` | Optional absolute GitHub API base URL. The public GitHub API is the default. | -| `GITHUB_SERVER_URL` | Optional absolute GitHub server and upload base URL used with a custom API URL. | - -The command opens `homebrew/Casks/.rb` beneath the distribution root. The path must resolve to a nonempty regular file no larger than 1 MiB. Root-confined file access rejects a symbolic link that escapes the distribution directory. The cask must contain one literal `version ""` declaration, and that version must equal `GITHUB_REF_NAME` after removal of its leading `v`. - -Publication enforces these guarantees in order: - -1. Read the tap's default branch, head commit, and current cask. -2. Find the unique pull request whose base is the default branch and whose head is `release//v`. Multiple matching pull requests are a conflict. -3. Return `published` without mutation when the default branch already contains the exact generated bytes. -4. Refuse a different cask at the same or a newer version. A malformed current version also fails before mutation. -5. Create the deterministic publication branch from the observed default-branch commit when the branch is absent. -6. Accept an existing publication commit only when it has the observed default-branch commit as its sole parent, changes only `Casks/.rb`, classifies that path as added or modified, and contains the exact generated bytes. The command refuses every other branch state. -7. Commit the generated cask to an unchanged new branch. The update uses the observed blob SHA when the cask already exists. -8. Return `open` when a matching pull request already exists. Otherwise, open a non-draft pull request with maintainer edits and auto-merge disabled, then return `created`. - -After a pull request is merged, a later invocation returns `published` only when the default branch contains the exact generated cask. A merged pull request without those bytes, or a closed unmerged pull request, is a conflict. - -Repository reads and retryable writes use at most four attempts, waiting 1 second, 2 seconds, and 4 seconds between attempts. After a failed branch, file, or pull-request write, the command reads fresh state before retrying. This accepts a write that GitHub applied before losing the response without creating a duplicate commit or pull request. - -A missing or malformed flag, Actions variable, token, endpoint, or source commit is a configuration error and exits with code `2` before a tap request. A missing, malformed, empty, non-regular, or oversized generated cask exits with code `1` before a tap request. Repository failures, conflicts, and failed postconditions also exit with code `1`. Success exits with code `0`. - -### Reusable Homebrew publisher - -`.github/workflows/publish-homebrew.yml` publishes one generated cask only after the public GitHub Release job succeeds. The caller passes the authoritative `release-assets` artifact ID and digest, the exact checksum-signing workflow ref, the tap and cask names, and the Release App client ID. Set `publish-homebrew` to `true` to enable publication. The default is `false`. - -The reusable workflow declares `release-app-private-key` as an optional secret because a disabled call must not require or mint a tap credential. An enabled call requires the client ID and private key before any tap request. It verifies the artifact handoff and signed release bundle before minting a repository-scoped App token with only `contents: write` and `pull-requests: write` for the selected tap. The generated `homebrew/Casks/.rb` control file is protected by the Actions artifact digest but is deliberately excluded from `checksums.txt` and the GitHub Release assets. The GitHub Release publisher removes the Homebrew control after artifact-digest verification; the Homebrew publisher isolates it while verifying the signed bundle, then restores it for tap publication. - -The publisher returns the deterministic branch, pull request URL, and reconciled state. A successful first run returns `created`; a rerun while the same pull request remains open returns `open`; and a rerun after the exact cask reaches the tap's default branch returns `published`. The workflow never enables auto-merge or merges the pull request. - -The producer's `.goreleaser.yaml` must declare a `homebrew_casks` entry with `skip_upload: true`. The Go pre-publish workflow carries `dist/homebrew/Casks/*.rb` in the authoritative Actions artifact and formats generated casks with Homebrew before upload. It does not add the control file to the signed release payload set. - -### Optional native package signing - -`.github/workflows/go-pre-publish.yml` accepts `sign-native-packages`, which defaults to `false`. Enabling it requires four optional workflow secrets: - -- `rpm-signing-key`, containing a base64-encoded OpenPGP private key; -- `rpm-signing-passphrase`; -- `apk-signing-key`, containing a base64-encoded RSA private key; and -- `apk-signing-passphrase`. - -The workflow validates the four secrets before setup. Immediately before staging, it decodes the private keys into owner-only files under `RUNNER_TEMP`. It passes these values to `release-cli stage`: - -| Value | Environment variable | -| --- | --- | -| Enable native package signing | `RELEASE_NATIVE_PACKAGE_SIGNING` | -| RPM private-key file | `RELEASE_RPM_SIGNING_KEY_FILE` | -| APK private-key file | `RELEASE_APK_SIGNING_KEY_FILE` | -| RPM private-key passphrase | `NFPM_RELEASE_RPM_PASSPHRASE` | -| APK private-key passphrase | `NFPM_RELEASE_APK_PASSPHRASE` | - -When `RELEASE_NATIVE_PACKAGE_SIGNING` parses as `true`, the stage command requires every other value before it starts GoReleaser. Each key path must identify a regular file whose group and other permission bits are clear. Missing values, malformed booleans, inaccessible files, and exposed file permissions are usage errors with exit code `2`. Error output names the invalid variable but does not include a passphrase. - -The producer's `.goreleaser.yaml` must use `release` as the `nfpms` ID and map the RPM and APK signature key files to `RELEASE_RPM_SIGNING_KEY_FILE` and `RELEASE_APK_SIGNING_KEY_FILE`. The fixed ID makes GoReleaser read the format-specific passphrases from `NFPM_RELEASE_RPM_PASSPHRASE` and `NFPM_RELEASE_APK_PASSPHRASE`. GoReleaser signs the native packages before it generates `checksums.txt`, so the signed package bytes are the bytes authenticated by the checksum manifest and its Cosign signature. - -After the stage step, the workflow removes the temporary key directory even when staging fails. When signing is disabled, the stage command removes ambient native-signing secrets from the GoReleaser environment and supplies empty key paths. Native package bytes remain unsigned and otherwise unchanged. - -### Optional macOS signing and notarization - -`.github/workflows/go-pre-publish.yml` accepts `sign-and-notarize-macos`, which defaults to `false`. Enabling it requires all five optional workflow secrets: - -- `macos-sign-p12`; -- `macos-sign-password`; -- `macos-notary-key`; -- `macos-notary-key-id`; -- `macos-notary-issuer-id`. - -The workflow fails before staging when any enabled credential is absent. GoReleaser uses Quill to sign and notarize every Darwin build, waits up to 20 minutes for Apple to accept each submission, and archives only accepted binaries. Apple rejection or timeout fails pre-publish, so neither the GitHub Release nor Homebrew publisher runs. - -When signing is disabled, the workflow does not require Apple credentials. Existing external callers therefore preserve their credential-free release path. Producers that enable signing must add a guarded `notarize.macos` block to `.goreleaser.yaml`; a workflow input alone cannot add signing policy to a producer's GoReleaser configuration. - -## Scoop manifest publication - -`release-cli publish scoop` reads the manifest generated by GoReleaser and reconciles it through a bucket pull request. The command never writes the bucket's default branch, force-updates a branch, deletes a path, enables auto-merge, or merges the pull request. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Distribution directory | `--dist` | `RELEASE_DIST` | None. A path is required. | -| Target bucket | `--bucket` | None. | None. Use `owner/repository` form. | -| Manifest name | `--manifest` | None. | None. Use lowercase letters, digits, and interior hyphens. | -| Release App installation token | None. | `RELEASE_APP_TOKEN` | None. A token is required. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -The command requires this GitHub Actions context: - -| Variable | Value | -| --- | --- | -| `GITHUB_REPOSITORY` | Source repository in `owner/name` form. | -| `GITHUB_REF_NAME` | Stable release tag. | -| `GITHUB_SHA` | Expected 40-character lowercase commit SHA for the workflow run. | -| `GITHUB_API_URL` | Optional absolute GitHub API base URL. The public GitHub API is the default. | -| `GITHUB_SERVER_URL` | Optional absolute GitHub server and upload base URL used with a custom API URL. | - -The command opens `scoop/.json` beneath the distribution root. The path must resolve to a nonempty regular file no larger than 1 MiB. Root-confined file access rejects a symbolic link that escapes the distribution directory. The generated JSON must parse and contain exactly a string `version` value equal to `GITHUB_REF_NAME` after removal of its leading `v`. Other JSON fields remain allowed; the publisher does not rewrite content. The repository write path remains `.json` at the bucket root. - -The producer's `.goreleaser.yaml` declares the `meigma-release-cli` Scoop manifest for `meigma/scoop-bucket`, selects the `release-cli` archive ID, uses the GitHub release asset URL template, and sets `skip_upload: true`. GoReleaser therefore writes `dist/scoop/meigma-release-cli.json` for the reviewed publisher without pushing directly to the bucket. - -Publication enforces these guarantees in order: - -1. Read the bucket's default branch, head commit, and current manifest. -2. Find the unique pull request whose base is the default branch and whose head is `release//v`. Multiple matching pull requests are a conflict. -3. Return `published` without mutation when the default branch already contains the exact generated bytes. -4. Refuse a different manifest at the same or a newer version. A malformed current version also fails before mutation. -5. Create the deterministic publication branch from the observed default-branch commit when the branch is absent. -6. Accept an existing publication commit only when it has the observed default-branch commit as its sole parent, changes only `.json`, classifies that path as added or modified, and contains the exact generated bytes. The command refuses every other branch state. -7. Commit the generated manifest to an unchanged new branch. The update uses the observed blob SHA when the manifest already exists. -8. Return `open` when a matching pull request already exists. Otherwise, open a non-draft pull request with maintainer edits and auto-merge disabled, then return `created`. - -After a pull request is merged, a later invocation returns `published` only when the default branch contains the exact generated manifest. A merged pull request without those bytes, or a closed unmerged pull request, is a conflict. - -Repository reads and retryable writes use at most four attempts, waiting 1 second, 2 seconds, and 4 seconds between attempts. After a failed branch, file, or pull-request write, the command reads fresh state before retrying. This accepts a write that GitHub applied before losing the response without creating a duplicate commit or pull request. - -A missing or malformed flag, Actions variable, token, endpoint, or source commit is a configuration error and exits with code `2` before a bucket request. A missing, malformed, empty, non-regular, or oversized generated manifest exits with code `1` before a bucket request. Repository failures, conflicts, and failed postconditions also exit with code `1`. Success exits with code `0`. - -### Reusable Scoop publisher - -`.github/workflows/publish-scoop.yml` publishes one generated manifest only -after the public GitHub Release succeeds. The caller passes the authoritative -`release-assets` artifact ID and digest, the exact checksum-signing workflow -ref, the bucket and manifest names, and the Release App client ID. Set -`publish-scoop` to `true` to enable publication. The default is `false`. - -The reusable workflow declares `release-app-private-key` as optional because a -disabled call must not require credentials, mint a token, or contact a bucket. -An enabled call requires the App client ID and private key before any bucket -request. It verifies the artifact handoff and signed release bundle before -minting a token scoped to only the selected bucket with `contents: write` and -`pull-requests: write`. - -The generated `scoop/.json` control is protected by the Actions -artifact digest but is deliberately excluded from `checksums.txt`, GitHub -attestations, and public release assets. The Scoop publisher requires exactly -one expected manifest beneath `dist/scoop`, isolates it, removes the unrelated -`dist/homebrew` control, verifies the remaining signed bundle, then restores the -manifest for `release-cli publish scoop`. The GitHub Release publisher removes -both package-manager controls before verification and upload. The Homebrew -publisher removes the Scoop control while preserving its existing cask -isolation, verification, restoration, and publication sequence. - -The reusable workflow exposes `branch`, `pull-request-url`, and `state`. It -accepts only `created`, `open`, and `published`. `created` and `open` require a -pull request URL; every state requires the deterministic branch. The workflow -never merges or enables auto-merge. - -The production release caller runs Scoop and Homebrew publication independently -after `github-release`. Both jobs require the successful `release-assets` and -`github-release` jobs, so neither package-manager repository can receive a pull -request for an unpublished or invalid release. - -## Signed release bundle verification - -`release-cli verify bundle` verifies the local release bundle before the GitHub Release workflow attests or uploads it. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Distribution directory | `--dist` | `RELEASE_DIST` | None. A path is required. | -| Certificate identity | `--identity` | `RELEASE_IDENTITY` | None. An exact, absolute HTTPS URL with a host is required. | -| Certificate OIDC issuer | `--issuer` | `RELEASE_ISSUER` | `https://token.actions.githubusercontent.com`. The effective value must be an absolute HTTPS URL with a host. | -| Cosign binary | None. | `RELEASE_COSIGN_PATH` | Resolve `cosign` from `PATH`. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -An explicitly set flag takes precedence over its environment variable. The identity and issuer must each be an absolute HTTPS URL with a host. `RELEASE_COSIGN_PATH` has no corresponding flag; set it to the Cosign executable when `cosign` is not on `PATH`. - -All local checks precede the Cosign invocation. The local checks enforce these requirements: - -- `checksums.txt` and `checksums.txt.sigstore.json` are regular files. -- Every `checksums.txt` entry is a regular file whose SHA-256 digest matches the manifest. -- Neither control file is listed as a payload. -- The distribution directory contains only the listed payloads and the two control files. An extra file, directory, symbolic link, or other entry fails this closed-set check. - -Only after the local checks succeed does the command verify the detached `checksums.txt.sigstore.json` bundle for `checksums.txt` against the exact certificate identity and OIDC issuer. Any local failure means Cosign is never invoked. - -Missing `--dist`/`RELEASE_DIST` or `--identity`/`RELEASE_IDENTITY`, and a malformed identity or issuer URL, are configuration errors and exit with code `2`. A local verification failure, a Sigstore verification failure, or an unresolvable Cosign binary exits with code `1`. - -## 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` builds the Go release bundle, validates the result, and writes the OCI input projection. - -| Value | Flag | Environment variable | Default | -| --- | --- | --- | --- | -| Profile | `--profile` | `RELEASE_PROFILE` | None. The only accepted value is `go`. | -| Distribution directory | `--dist` | `RELEASE_DIST` | None. A basename is required. | -| GoReleaser binary | None. | `RELEASE_GORELEASER_PATH` | Resolve `goreleaser` from `PATH` when the build starts. | -| JSON output | `--json` | `RELEASE_JSON` | Disabled. | - -An explicitly set flag takes precedence over its environment variable. `RELEASE_GORELEASER_PATH` is environment-only, as are `RELEASE_MELANGE_PATH`, `RELEASE_APKO_PATH`, `RELEASE_COSIGN_PATH`, `RELEASE_GH_PATH`, and `RELEASE_GIT_PATH`. The distribution directory must be a basename other than `.` or `..`; a value containing a path separator is invalid. It must name the same directory configured in `.goreleaser.yaml` because the GoReleaser invocation has no distribution-directory flag. - -The command performs these operations in order: - -1. Resolve and validate the CLI flags and environment variables. A missing profile or distribution directory, an unknown profile, a distribution value that is not a basename, or a malformed boolean environment value exits with code `2` before the build starts. -2. Resolve the configured GoReleaser executable and run `goreleaser release --clean --skip=publish`. -3. Read `checksums.txt`, require at least one payload entry, and verify every listed payload against its SHA-256 digest. -4. Require a nonempty regular `checksums.txt.sigstore.json`. This stage requires the bundle but does not verify its signature. -5. Read `artifacts.json` and require exactly two Linux `Binary` records: one for `amd64` and one for `arm64`. -6. Require each selected binary path to start with the distribution directory's basename. The remaining path must stay confined beneath that directory, and each selected binary must be a regular executable file. -7. Write `oci-build-inputs.json` into the distribution directory for the downstream OCI builder. - -The GoReleaser `--clean` option deletes and rebuilds the distribution directory. `stage --profile go` is therefore a build command, not read-only validation. An unresolvable GoReleaser executable or a failed GoReleaser process exits with code `1`; executable resolution fails before the process starts. A post-build validation or projection-write failure also exits with code `1` and identifies the offending artifact or operation. - -GoReleaser's stdout and stderr both go to the CLI's stderr stream. Under `--json`, the `release.dev/result/v1` envelope remains the only stdout content. - -The projection has schema `release.dev/oci-build-inputs/v1` and contains these fields: - -| Field | JSON type | Value | -| --- | --- | --- | -| `schema` | string | Always `release.dev/oci-build-inputs/v1`. | -| `profile` | string | Staging profile that produced the projection. For this profile, the value is `go`. | -| `binaries` | array of objects | Exactly one canonical binary for `linux/amd64` and one for `linux/arm64`. | -| `binaries[].platform` | string | Canonical platform: `linux/amd64` or `linux/arm64`. | -| `binaries[].name` | string | Shared, nonempty binary filename. Both Linux binaries must have the same name. | -| `binaries[].path` | string | Confined, artifact-root-relative path to the canonical binary. | -| `binaries[].digest` | string | SHA-256 digest of the canonical binary, as `sha256:` followed by 64 lowercase hexadecimal digits. | - -The projection records staged facts only. The image builder recomputes each digest from the downloaded bytes before packaging. - -## Profiles - -`--profile` selects ecosystem-specific staging rules while keeping `stage` as the command. The current implementation dispatches `go` directly and rejects every other value with exit code `2`. New ecosystems extend the accepted profile values rather than adding ecosystem-specific top-level verbs. - -## Nix flake - -The repository root is a Nix flake with these outputs for each supported -system: - -| Output | Meaning | -| --- | --- | -| `packages..release-cli` | Source-built `release-cli` package. | -| `packages..default` | Alias of the `release-cli` package. | -| `apps..release-cli` | Runnable `release-cli` application. | -| `apps..default` | Alias of the `release-cli` application. | -| `checks..release-cli` | Package build used by `nix flake check`. | - -The supported systems are `aarch64-darwin`, `aarch64-linux`, -`x86_64-darwin`, and `x86_64-linux`. Nixpkgs 26.05 is pinned because it is the -last Nixpkgs release that supports `x86_64-darwin`. - -The package builds `cmd/release-cli` from the exact flake source with CGO -disabled. It uses Go 1.26.6 from a fixed source hash and downloads Go modules -through the fixed `vendorHash`. Linker flags embed the version from -`.release-please-manifest.json` and the flake source revision. - -The flake does not expose an overlay or NixOS module. It does not install the -prebuilt GitHub Release archive or verify its GitHub artifact attestation. -Consumers pin the source, Nixpkgs, Go source, and Go module dependency content -through `flake.lock` and the fixed-output hashes. - -## Release unit and consumer pin - -The reusable workflows, `.github/actions/setup-release-cli`, and `release-cli` form one release unit and share one version. The producer loads the sibling action with `uses: ./.github/actions/setup-release-cli`. A consumer pins the workflow at one full commit SHA, and that self-reference selects the action from the same commit and its stamped default CLI version. The current released pin is `0fc99489d31d400bc3f69d6636d60e7d3f3d0251` (`v0.1.3`). Consumers cannot select an independent CLI version. - -Direct CLI users can install a tagged release through -[mise's built-in GitHub backend](../how-to/install-release-cli-with-mise.md) or -[the repository's Nix flake](../how-to/install-release-cli-with-nix.md). These -installation paths do not change the release-unit pin used by reusable workflow -consumers. - -The setup action has two optional inputs: - -| Input | Required | Meaning | -| --- | --- | --- | -| `cli-path` | No | Unsupported path to a caller-supplied `release-cli` binary. The caller owns the workflow-to-binary pairing. | -| `local-build` | No | Acquisition policy: `auto`, `always`, or `never`. The default is `auto`. | - -With `local-build: auto`, the action builds from source only when the caller and reusable workflow belong to the same repository and the current version tag matches the action's stamped version. `always` forces a source build, and `never` forces installation of the stamped release. A nonempty `cli-path` takes precedence unless it conflicts with `local-build: always`. - -For a source build, the action requires the sibling action and reusable workflow to come from the same repository, uses the source beside that action, and requires the runner-provided reusable workflow SHA. It reads the pinned Go patch version from `go.mod`, restores exact OS, architecture, Go version, and source SHA caches for `GOCACHE` and `GOMODCACHE`, and builds with the stamped version and workflow SHA. A cache miss performs a complete build. The executable itself is not cached. - -For an installed release, the action requires `github.action_repository` and a runner-provided `gh` with attestation support. It downloads exactly one Linux amd64 archive and `checksums.txt`, verifies the archive's unique SHA-256 entry, and runs `gh attestation verify` against the action repository with `--signer-workflow /.github/workflows/publish-github-release.yml` and `--deny-self-hosted-runners`. - -Both supported acquisition modes require the binary's reported version and protocol to match the action stamps before a workflow invokes a CLI command. With `cli-path`, the supplied path must exist, be a regular file, and be executable. A version or protocol mismatch warns and continues because the caller owns that unsupported pairing. - -The action exposes `cli-path`, `reported-version`, and `reported-protocol` as outputs. - -## Released archive names - -GoReleaser names archives with this pattern: - -```text -release-cli___.tar.gz -``` - -Windows archives use `.zip`. For example, the Linux amd64 archive is `release-cli__linux_amd64.tar.gz`. The checksum manifest is `checksums.txt`. diff --git a/docs/reference/release-cli.md b/docs/reference/release-cli.md new file mode 100644 index 0000000..f818bf1 --- /dev/null +++ b/docs/reference/release-cli.md @@ -0,0 +1,528 @@ +# `release-cli` reference + +`release-cli` is the policy executable used by the reusable workflows. This +page describes its command-line interface. Workflow interfaces, artifacts, and +publication states are defined in the [release system reference](release-system.md). + +## Global behavior + +### Command summary + +| Command | Effect | +| --- | --- | +| `stage --profile go` | Run GoReleaser, validate the release bundle, and project canonical OCI inputs. | +| `image build` | Build signed APK repositories and a locked OCI layout from staged binaries. | +| `image verify` | Verify the OCI layout, runtime files, annotations, SBOMs, and index digest. | +| `plan tags` | Read registry state and report exact and channel tag decisions. | +| `publish oci prepare` | Validate, push, verify, and recursively sign an image by digest without applying tags. | +| `publish oci finalize` | Re-read registry state and apply eligible tags after attestation. | +| `publish github` | Reconcile a closed bundle with one matching GitHub Release and optionally undraft it. | +| `publish homebrew` | Reconcile one generated cask through a tap pull request. | +| `publish scoop` | Reconcile one generated root manifest through a bucket pull request. | +| `publish package-repository` | Verify a producer release and converge an APT/DNF/APK repository in R2. | +| `init homebrew-tap` | Generate a cask-only tap scaffold. | +| `init scoop-bucket` | Generate a root-layout Scoop bucket scaffold. | +| `verify bundle` | Verify a closed local release bundle and its Cosign signature. | +| `verify handoff` | Verify an Actions artifact's API metadata before download. | +| `version` | Report version, source commit, and protocol. | + +### Configuration precedence + +An explicitly set flag overrides its corresponding `RELEASE_*` environment +variable. The environment value overrides a derived default. A value with no +flag is environment-only. + +Boolean environment variables use Go's `strconv.ParseBool` values: +`1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, or +`False`. Another value is invalid configuration. + +### Output + +With `--json` or `RELEASE_JSON=true`, stdout contains exactly one JSON document +after argument parsing succeeds: + +```json +{ + "schema": "release.dev/result/v1", + "command": "", + "ok": true, + "result": {} +} +``` + +A command or configuration failure after dispatch sets `ok` to `false`, puts an +`error` string in `result`, and preserves the nonzero exit code. Unknown flags +and invalid flag values write usage to stderr without a JSON envelope. Unknown +commands and wrong argument counts also have no envelope, but automatic usage +output is suppressed. + +Without JSON, `version` writes its requested data to stdout. Other successful +commands are silent on stdout. Diagnostics, warnings, and tool output go to +stderr. + +### Exit codes + +| Code | Meaning | +| ---: | --- | +| `0` | Success. | +| `1` | Tool, network, verification, state, or publication failure. | +| `2` | Usage or configuration failure. | + +No other exit code is defined. Exit code `1` does not by itself mean that a +mutation is safe to retry. + +## `stage --profile go` + +```text +release-cli stage --profile go --dist DIR [--json] +``` + +| Value | Flag | Environment | Default | +| --- | --- | --- | --- | +| Profile | `--profile` | `RELEASE_PROFILE` | Required; only `go`. | +| Distribution basename | `--dist` | `RELEASE_DIST` | Required. | +| GoReleaser executable | None | `RELEASE_GORELEASER_PATH` | Resolve `goreleaser` from `PATH`. | +| JSON | `--json` | `RELEASE_JSON` | `false` | + +`DIR` is a basename other than `.` or `..` and must match GoReleaser's output +directory. The command invokes exactly: + +```text +goreleaser release --clean --skip=publish +``` + +The command then verifies every `checksums.txt` entry, requires a regular +`checksums.txt.sigstore.json`, reads `artifacts.json`, requires one static +executable Linux binary for `amd64` and one for `arm64`, confines both paths, +and writes `oci-build-inputs.json`. + +Native package signing is controlled by environment only: + +| Environment | Contract | +| --- | --- | +| `RELEASE_NATIVE_PACKAGE_SIGNING` | Enable signing when true. | +| `RELEASE_RPM_SIGNING_KEY_FILE` | Owner-only regular OpenPGP private-key file. | +| `RELEASE_APK_SIGNING_KEY_FILE` | Owner-only regular RSA private-key file. | +| `NFPM_RELEASE_RPM_PASSPHRASE` | Passphrase selected by nFPM ID `release`. | +| `NFPM_RELEASE_APK_PASSPHRASE` | Passphrase selected by nFPM ID `release`. | + +When signing is disabled, inherited native-signing values are replaced with +empty values so GoReleaser templates cannot use ambient credentials. When +enabled, a missing value, malformed boolean, inaccessible file, or +group/other-readable key is configuration error `2` before GoReleaser starts. + +JSON result: + +| Field | Contract | +| --- | --- | +| `assets` | Number of checksum-verified payloads. | +| `binaries.amd64.path`, `binaries.arm64.path` | Original dist-prefixed paths from `artifacts.json`. | +| `binaries..mode` | Observed permission bits in octal. | + +`--clean` deletes and rebuilds the distribution directory. This command is not +read-only. + +## `image build` + +```text +release-cli image build --input DIR --work DIR --output DIR \ + [--melange-config PATH] [--apko-config PATH] \ + --build-date RFC3339 [--version VERSION] [--json] +``` + +| Value | Flag | Environment | Default | +| --- | --- | --- | --- | +| Input root | `--input` | `RELEASE_INPUT` | Required. | +| Scratch root | `--work` | `RELEASE_WORK` | Required. | +| Output root | `--output` | `RELEASE_OUTPUT` | Required. | +| Melange config | `--melange-config` | `RELEASE_MELANGE_CONFIG` | `melange.yaml` | +| apko config | `--apko-config` | `RELEASE_APKO_CONFIG` | `apko.yaml` | +| Build date | `--build-date` | `RELEASE_BUILD_DATE` | Required RFC 3339. | +| Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` without one leading `v`. | +| Melange executable | None | `RELEASE_MELANGE_PATH` | Resolve from `PATH`. | +| apko executable | None | `RELEASE_APKO_PATH` | Resolve from `PATH`. | + +Required Actions context is `GITHUB_REPOSITORY`, `GITHUB_REPOSITORY_OWNER`, +`GITHUB_SERVER_URL`, and `GITHUB_SHA`. Work and output roots must be disjoint, +absent or empty, and neither may contain the other. + +The command verifies the projected binary digests and ELF contract, stages each +as `application`, creates an ephemeral Melange signing key, builds `x86_64` and +`aarch64` APK repositories, writes the public key, locks apko, and composes the +OCI layout and architecture SBOMs. The private Melange key remains under the +scratch root. + +`oci-build-inputs.json` is limited to 4 MiB. + +The `release.dev/image-build/v1` JSON result contains `version`, `binary`, +`work`, `output`, `build_date`, and two `packages` entries. Each package entry +contains `platform`, APK `arch`, output-relative `package`, and +`binary_digest`. + +## `image verify` + +```text +release-cli image verify --output DIR --work DIR --binary NAME \ + [--version VERSION] [--json] +``` + +| Value | Flag | Environment | Default | +| --- | --- | --- | --- | +| Output root | `--output` | `RELEASE_OUTPUT` | Required. | +| Scratch root | `--work` | `RELEASE_WORK` | Required. | +| Binary | `--binary` | `RELEASE_BINARY` | Required. | +| Version | `--version` | `RELEASE_VERSION` | Tag name without one leading `v`. | + +The command also requires `GITHUB_SHA`, `GITHUB_SERVER_URL`, and +`GITHUB_REPOSITORY`. It verifies: + +- one OCI index with Linux `amd64` and `arm64` manifests; +- required source, version, revision, title, description, and license + annotations on index, manifests, and config labels; +- one layer, entrypoint `/usr/bin/`, and runtime user `65532`; +- one regular binary with ownership `0:0`, mode `0755`, no special bits, and + bytes equal to the staged canonical binary; and +- one SPDX `APPLICATION` package at `-r0` per architecture. + +Index, manifest, config, and SPDX JSON documents are limited to 4 MiB. The +binary entry in each layer is limited to 64 MiB. + +The index digest is SHA-256 of the exact `layout/index.json` bytes. On success, +the command writes it to `image-digest.txt` and returns a +`release.dev/image-verify/v1` result containing `version`, `binary`, +`index_digest`, and two platform records with manifest, config, layer, and +binary digests. + +## `plan tags` + +```text +release-cli plan tags [--image IMAGE] [--version VERSION] \ + --digest sha256:HEX [--plain-http] [--json] +``` + +| Value | Flag | Environment | Default | +| --- | --- | --- | --- | +| Image | `--image` | `RELEASE_IMAGE` | Lowercase caller GHCR path. | +| Version | `--version` | `RELEASE_VERSION` | Tag name without one leading `v`. | +| Digest | `--digest` | `RELEASE_DIGEST` | Required. | +| Plain HTTP | `--plain-http` | None | `false` | + +The image is an untagged `host/path` value. `--plain-http` is accepted only for +`localhost`, `127.0.0.1`, or `::1`, optionally with a port. + +Registry token precedence is nonempty `GITHUB_TOKEN`, then nonempty `GH_TOKEN`. +Username precedence is `GITHUB_ACTOR`, then `x-access-token`. Without a token, +reads are anonymous. + +The command reads only. It reports decisions for exact, minor, major, and +`latest` tags. JSON fields are `image`, `version`, normalized `digest`, `tags` +to create, and `decisions` with `tag`, `scope`, and `action` (`create`, `accept`, +or `retain`). A direct planner has no cross-process writer lock. + +## `publish oci prepare` + +```text +release-cli publish oci prepare --layout DIR [--image IMAGE] \ + [--version VERSION] --digest sha256:HEX [--dry-run] \ + [--plain-http] [--json] +``` + +`--layout`/`RELEASE_LAYOUT` and `--digest`/`RELEASE_DIGEST` are required. Image, +version, credentials, and loopback-only plain HTTP resolve as for `plan tags`. +`--dry-run` or `RELEASE_DRY_RUN=true` disables every registry write and Cosign +invocation. + +Enabled preparation: + +1. validates the layout and exact index digest; +2. reads current exact and channel tags and rejects an immutable conflict; +3. pushes unique blobs, platform manifests, and index by digest; +4. resolves and verifies each pushed digest; and +5. invokes `cosign sign --yes --recursive @`. + +The inspected OCI index and manifest JSON documents are limited to 4 MiB. + +`RELEASE_COSIGN_PATH` overrides the Cosign executable. The command never +creates or moves a tag. + +The `release.dev/oci-prepare/v1` result contains `authoritative`, `image`, +`version`, `index_digest`, platform digests, and the observed exact/channel tag +state. Dry-run results set `authoritative` to `false` and cannot be finalized. + +## `publish oci finalize` + +```text +release-cli publish oci finalize --result - [--plain-http] [--json] +``` + +`--result` is required and accepts only `-`, meaning stdin. The input is limited +to 4 MiB and must contain exactly one successful `release.dev/result/v1` +envelope from `publish oci prepare --json`, with a valid authoritative +`release.dev/oci-prepare/v1` result. Files, trailing JSON, dry-run results, and +other commands are rejected. An oversized or malformed envelope is usage error +`2`. + +Finalization re-reads every planned tag, compares fresh state with preparation, +accepts unchanged state or tags already on the candidate, rejects other drift, +recomputes the plan, writes tags serially, and independently verifies each +resolution. + +The `release.dev/oci-finalize/v1` result contains `image`, `version`, +`index_digest`, and tag arrays `applied`, `accepted`, and `retained`. A saved +prepare envelope is not a durable receipt and must not be replayed later. + +## `publish github` + +```text +release-cli publish github --dist DIR [--no-undraft] [--json] +``` + +| Value | Flag | Environment | Default | +| --- | --- | --- | --- | +| Distribution root | `--dist` | `RELEASE_DIST` | Required. | +| Keep draft | `--no-undraft` | None | `false` | +| GitHub CLI | None | `RELEASE_GH_PATH` | Resolve `gh`. | +| Git | None | `RELEASE_GIT_PATH` | Resolve `git`. | +| App token | None | `RELEASE_APP_TOKEN` | Required. | + +Required Actions context is `GITHUB_REPOSITORY`, `GITHUB_REF_NAME`, and one full +lowercase `GITHUB_SHA`. Optional `GITHUB_API_URL` and `GITHUB_SERVER_URL` +select GitHub Enterprise endpoints. + +The command rebuilds the closed asset set from `checksums.txt` and its two +controls. It does not repeat Cosign verification; the reusable workflow runs +`verify bundle` before attestation and publication. + +Publication order: + +1. require the tag to resolve to `GITHUB_SHA`; +2. poll for exactly one release with that tag; +3. require a draft for mutation; +4. reject an existing unexpected asset name; +5. upload every expected name with clobber semantics; +6. wait for exact names, uploaded states, and GitHub SHA-256 digests; +7. undraft last unless `--no-undraft`; and +8. read and require the requested final state. + +An already-public release is never mutated. Without `--no-undraft`, an exact +public match is a successful completed-publication retry; another state is +indeterminate. With `--no-undraft`, every public state is indeterminate. The +command never creates, re-drafts, or deletes a release. It refuses unexpected +asset names, but clobber convergence can replace an expected same-name asset. + +JSON result fields are `release_id`, `tag`, `url`, final `draft`, and sorted +`assets`. + +## `init homebrew-tap` + +```text +release-cli init homebrew-tap --tap OWNER/HOMEBREW-NAME \ + --output DIR [--json] +``` + +The output path must be absent or an empty directory. The CLI source-commit +stamp must be one full lowercase SHA; the version stamp is not validated. The +command writes exactly: + +```text +.github/workflows/casks.yml +.github/dependabot.yml +Casks/.gitkeep +README.md +``` + +The validation workflow pins `meigma/release` to the CLI source commit. The +command performs no Git or GitHub request. JSON fields are `tap`, `output`, and +lexically sorted `files`. + +## `init scoop-bucket` + +```text +release-cli init scoop-bucket --bucket OWNER/REPOSITORY \ + --output DIR [--json] +``` + +The same output and source-commit-stamp conditions apply. The command writes +exactly: + +```text +.gitattributes +.github/workflows/manifests.yml +.github/dependabot.yml +README.md +``` + +It creates no sample manifest and performs no remote operation. JSON fields are +`bucket`, `output`, and sorted `files`. + +## `publish homebrew` + +```text +release-cli publish homebrew --dist DIR --tap OWNER/REPOSITORY \ + --cask TOKEN [--json] +``` + +`--dist` or `RELEASE_DIST`, `--tap`, `--cask`, and `RELEASE_APP_TOKEN` are +required. The cask token uses lowercase letters, digits, and interior hyphens. +Required Actions context is `GITHUB_REPOSITORY`, stable `GITHUB_REF_NAME`, and +full lowercase `GITHUB_SHA`. + +The command reads `homebrew/Casks/.rb`, which must be a confined, +nonempty, regular file no larger than 1 MiB with one matching literal version. +It reads the destination default branch and matching deterministic pull request, +accepts exact published or open state, rejects conflicting or newer content, +and otherwise creates one child commit that changes only the cask and opens a +non-draft pull request with auto-merge disabled. + +JSON fields are `tap`, `cask`, deterministic `branch`, `pull_request_url`, and +`state` (`created`, `open`, or `published`). + +## `publish scoop` + +```text +release-cli publish scoop --dist DIR --bucket OWNER/REPOSITORY \ + --manifest NAME [--json] +``` + +Configuration and Actions context match Homebrew publication. The command reads +`scoop/.json`, a confined nonempty regular file no larger than 1 MiB. +It requires one string `version` equal to the stable source tag without `v` and +writes the exact bytes to root `.json` through the same deterministic +branch and pull-request state machine. + +JSON fields are `bucket`, `manifest`, `branch`, `pull_request_url`, and the same +three `state` values. + +Neither destination publisher writes the default branch, force-updates, deletes +a ref, merges, approves, or enables auto-merge. + +## `publish package-repository` + +```text +release-cli publish package-repository [flags] +``` + +| Flag | Environment | Required value | +| --- | --- | --- | +| `--repository` | `RELEASE_REPOSITORY` | Lowercase producer `owner/name`. | +| `--tag` | `RELEASE_TAG` | Stable `vMAJOR.MINOR.PATCH`. | +| `--config` | `RELEASE_PACKAGE_REPOSITORY_CONFIG` | Strict policy YAML. | +| `--keys` | `RELEASE_PACKAGE_KEYS` | Confined reviewed public-key root. | +| `--cloudflare-account-id` | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID. | +| `--r2-bucket` | `RELEASE_R2_BUCKET` | Existing bucket. | +| `--gpg-home` | `RELEASE_GPG_HOME` | Absolute owner-only GnuPG home containing the aggregate secret key. | +| `--gpg-key-id` | `RELEASE_GPG_KEY_ID` | Nonempty aggregate OpenPGP key selector; the maintained workflow passes the full fingerprint. | +| `--gpg-passphrase-file` | `RELEASE_GPG_PASSPHRASE_FILE` | Absolute owner-only regular passphrase file. | +| `--apk-signing-key` | `RELEASE_APK_SIGNING_KEY` | Absolute owner-only regular aggregate APK RSA private-key file. | + +Environment-only values: + +- `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`; +- `GITHUB_TOKEN`, falling back to `GH_TOKEN`; +- optional `GITHUB_API_URL` and `GITHUB_SERVER_URL`; and +- executable overrides `RELEASE_GH_PATH`, `RELEASE_DOCKER_PATH`, + `RELEASE_COSIGN_PATH`, and `RELEASE_GPG_PATH`. + +The command accepts no operands. It verifies the public producer release closed +set, exact checksum identity from policy, GitHub asset digests and attestations, +package metadata, and producer RPM/APK signatures. It mirrors existing immutable +packages, regenerates all metadata, signs aggregate roots, installs locally with +APT/DNF/APK, uploads non-root objects before roots, and repeats installation +from the public origin. + +JSON result fields are `state` (`published` or `unchanged`), `repository`, +`tag`, generated `artifacts`, and `uploaded`. The command does not create the +bucket, keys, policy, public domain, GitHub environment, or producer release and +never deletes an R2 object. + +## `verify bundle` + +```text +release-cli verify bundle --dist DIR --identity HTTPS-URL \ + [--issuer HTTPS-URL] [--json] +``` + +| Value | Flag | Environment | Default | +| --- | --- | --- | --- | +| Distribution root | `--dist` | `RELEASE_DIST` | Required. | +| Certificate identity | `--identity` | `RELEASE_IDENTITY` | Required. | +| OIDC issuer | `--issuer` | `RELEASE_ISSUER` | `https://token.actions.githubusercontent.com` | +| Cosign executable | None | `RELEASE_COSIGN_PATH` | Resolve `cosign`. | + +Identity and issuer must be absolute HTTPS URLs with hosts. Before invoking +Cosign, the command verifies regular controls, every payload digest, control +exclusion, and the exact closed directory set. A local failure prevents the +Cosign invocation. + +JSON fields are `dist`, `identity`, `issuer`, ordered `payloads`, and ordered +`controls`. Each file entry contains `name` and lowercase SHA-256 `digest` +without a prefix. + +## `verify handoff` + +```text +release-cli verify handoff --artifact-id N --digest sha256:HEX [--json] +``` + +`RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST` are the environment alternatives. +The ID is a positive decimal safe integer. Digest hex is case-insensitive and +normalizes to lowercase with a `sha256:` prefix. + +Actions context and token: + +| Environment | Contract | +| --- | --- | +| `GITHUB_REPOSITORY` | Caller `owner/name`. | +| `GITHUB_RUN_ID` | Positive current run ID. | +| `GITHUB_TOKEN`, then `GH_TOKEN` | API token. | +| `GITHUB_API_URL`, `GITHUB_SERVER_URL` | Optional enterprise endpoints. | + +The command reads metadata only. It requires the artifact to exist, belong to +the current run, be unexpired, and have the expected GitHub-reported digest. It +does not download the artifact or reproduce the artifact ZIP digest. + +JSON contains `artifact.id`, `name`, normalized `digest`, `size_bytes`, +`run_id`, and `expires_at`. + +## `version` + +```text +release-cli version [--json] +``` + +Human output is: + +```text +release-cli (, protocol ) +``` + +JSON result fields are `version`, `commit`, and integer `protocol`. The current +protocol value is `1`. Reusable setup requires the reported version and protocol +to match the action's release stamps for supported acquisition modes. + +## Retry behavior + +Actions metadata, GitHub publication, Homebrew, Scoop, and selected retryable +OCI operations use at most four attempts with waits of 1, 2, and 4 seconds. +Artifact metadata retries only rate limits and HTTP `5xx`. + +Missing-draft polling makes at most 24 observations and waits five seconds +after each miss, including the final exhausted miss. Asset convergence makes at +most 12 observations and waits one second after each incomplete observation, +including the final exhausted observation. Transient GitHub API failures within +an observation use the four-attempt policy. + +Homebrew and Scoop read fresh state after a failed write. OCI preparation +retries transient blob pushes and digest verification; finalization retries tag +commits and postcondition reads. Tag planning and preparation's initial tag +collection do not use that four-attempt helper. The package-repository policy +layer does not add a retry loop; a new invocation converges from current R2 +object digests and sizes. + +Authentication and configuration failures, absent Actions handoff artifacts, +missing required local inputs, tag/commit or digest mismatches, unexpected +assets, immutable conflicts, and conflicting destination state are not +retryable classes. A missing draft and an incomplete expected release-asset set +use the polling contracts above; absent R2 objects are uploaded during +convergence. An undraft request can leave an indeterminate public state; inspect +it before another invocation. diff --git a/docs/reference/release-system.md b/docs/reference/release-system.md new file mode 100644 index 0000000..b9f188f --- /dev/null +++ b/docs/reference/release-system.md @@ -0,0 +1,629 @@ +# Release system reference + +This reference defines the supported reusable workflow, repository, artifact, +signer, and publication contracts. For setup procedures, see +[Adopt the release workflows](../how-to/adopt-the-release-workflows.md). For +failure handling, see [Operate and recover releases](../how-to/operate-and-recover-releases.md). + +## Support matrix + +| Domain | Supported contract | +| --- | --- | +| Application layout | One Go application and binary per repository. | +| Source tags | Stable, unscoped `vMAJOR.MINOR.PATCH`. | +| Binary operating systems | Darwin, Linux, and Windows. | +| Binary architectures | `amd64` and `arm64`. | +| Linkage | Static binaries; the canonical Linux binaries must be static ELF executables. | +| GitHub Release | Archives, DEB/RPM/APK packages, archive/package SBOMs, checksum manifest, and Cosign bundle. | +| OCI image | Linux `amd64` and `arm64` at `ghcr.io//`. | +| Homebrew | One cask in an adopter-owned `homebrew-` tap. | +| Scoop | One manifest at the root of an adopter-owned bucket. | +| Native repository | One `stable` channel, `amd64` and `arm64`, DEB/RPM/APK, one Cloudflare R2 bucket. | + +## Release unit and revision invariant + +The reusable workflows, `.github/actions/setup-release-cli`, and `release-cli` +form one release unit. An external caller pins each `uses:` reference to one +reviewed, full 40-character commit SHA in `meigma/release`. The same SHA appears +in every `checksum-signing-workflow-ref` and in each native package policy +`checksum_identity` for that producer. + +A consumer does not select a separate composite-action revision or CLI version. +The pinned workflow loads its sibling setup action, whose release stamp selects +and verifies the matching CLI. Moving branches, tags, abbreviated SHAs, and +mixed release-unit revisions are unsupported. + +`REPLACE_WITH_RELEASE_COMMIT_SHA` in the maintained example is a template value. +The caller is not ready to run until every occurrence is replaced by one full +lowercase SHA. + +## Version and tag grammar + +The source ref must match: + +```text +^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ +``` + +The version has three unsigned decimal components without leading zeroes, +prerelease suffix, build metadata, or component prefix. Release Please uses an +unscoped `v` tag and creates the initial draft. The publishers do not calculate +a version, create a tag, create a release, or generate notes. + +For `v1.2.3`, OCI publication evaluates: + +| Tag | Contract | +| --- | --- | +| `1.2.3` | Immutable exact version. An existing different digest fails before upload. | +| `1.2` | Advances only to a newer stable version in the `1.2` line. | +| `1` | Advances only to a newer stable version in the `1` line. | +| `latest` | Advances only to a newer stable version. | + +An out-of-order release always publishes an available exact tag and leaves any +newer channel unchanged. Equal versions on different digests are corrupt state. + +## Workflow graph + +```text +release-please.yml + -> vMAJOR.MINOR.PATCH + draft + -> go-pre-publish.yml + -> release-assets + -> oci-build-inputs + -> go-oci-build.yml + -> oci-image + -> publish-oci-image.yml + -> digest-pinned image + -> publish-github-release.yml + -> public release + -> publish-homebrew.yml (optional, independent PR) + -> publish-scoop.yml (optional, independent PR) + -> request-package-repository.yml + -> adopter-owned central repository_dispatch + -> publish-package-repository.yml +``` + +`publish-github-release.yml` depends on OCI publication in the maintained +caller. Homebrew, Scoop, and native dispatch depend on the GitHub Release job. +The supported caller enables those destinations only when `publish-release` is +also enabled. The native receiver independently rejects a nonpublic release; +the Homebrew and Scoop publishers do not check public release state. + +Except for `publish-oci-image.yml`, each reusable workflow declares +`permissions: {}` at workflow scope. The OCI publisher declares +`artifact-metadata: read` there for attestation subject discovery. The calling +job always supplies the maximum token permissions; a callee cannot elevate +above that ceiling. + +## Caller permission ceilings + +| Called workflow | Required caller permissions | +| --- | --- | +| `go-pre-publish.yml` | `attestations: read`, `contents: read`, `id-token: write` | +| `go-oci-build.yml` | `actions: read`, `attestations: read`, `contents: read` | +| `publish-oci-image.yml` | `actions: read`, `artifact-metadata: write`, `attestations: write`, `contents: read`, `id-token: write`, `packages: write` | +| `publish-github-release.yml` | `actions: read`, `artifact-metadata: write`, `attestations: write`, `contents: read`, `id-token: write` | +| `publish-homebrew.yml` | `actions: read`, `attestations: read`, `contents: read` | +| `publish-scoop.yml` | `actions: read`, `attestations: read`, `contents: read` | +| `request-package-repository.yml` | `{}` | +| `publish-package-repository.yml` | `attestations: read`, `contents: read` | + +The Release Please job requires `contents: write`, `issues: write`, and +`pull-requests: write`. It performs mutations with an adopter-owned App token. + +## Reusable workflow interfaces + +### `go-pre-publish.yml` + +The producer runs on `ubuntu-24.04` with a 30-minute timeout. + +Inputs: + +| Input | Type | Required | Default | Contract | +| --- | --- | --- | --- | --- | +| `sign-and-notarize-macos` | boolean | No | `false` | Enable the producer's guarded GoReleaser macOS signing and notarization block. | +| `sign-native-packages` | boolean | No | `false` | Sign RPM and APK packages before checksum generation. | + +Optional secrets become required when their input is enabled: + +| Input | Required secrets | +| --- | --- | +| macOS signing | `macos-sign-p12`, `macos-sign-password`, `macos-notary-key`, `macos-notary-key-id`, `macos-notary-issuer-id` | +| Native signing | `rpm-signing-key`, `rpm-signing-passphrase`, `apk-signing-key`, `apk-signing-passphrase` | + +The private key values are base64 encoded. Native keys are materialized as +owner-only files under `RUNNER_TEMP` immediately before staging and removed +afterward, including on a failed stage. + +Outputs: + +| Output | Contract | +| --- | --- | +| `artifact-id` | Numeric ID of `release-assets`. | +| `artifact-url` | GitHub Actions artifact URL for `release-assets`. | +| `artifact-digest` | GitHub SHA-256 transport digest for `release-assets`. | +| `oci-input-artifact-id` | Numeric ID of `oci-build-inputs`. | +| `oci-input-artifact-url` | GitHub Actions artifact URL for `oci-build-inputs`. | +| `oci-input-artifact-digest` | GitHub SHA-256 transport digest for `oci-build-inputs`. | + +The workflow installs the producer's locked Go, GoReleaser, Syft, and Cosign, +sets up the release-unit CLI, and runs `release-cli stage --profile go --dist +dist`. It retains both artifacts for seven days without additional ZIP +compression. + +### `go-oci-build.yml` + +The OCI builder runs on `ubuntu-24.04` with a 20-minute timeout. + +| Input | Type | Required | Default | +| --- | --- | --- | --- | +| `artifact-id` | string | Yes | None | +| `artifact-digest` | string | Yes | None | +| `melange-config` | string | No | `melange.yaml` | +| `apko-config` | string | No | `apko.yaml` | + +| Output | Contract | +| --- | --- | +| `artifact-id` | Numeric ID of `oci-image`. | +| `artifact-url` | GitHub Actions artifact URL for `oci-image`. | +| `artifact-digest` | GitHub SHA-256 transport digest for `oci-image`. | +| `image-digest` | SHA-256 digest of the exact `layout/index.json` bytes. | + +The builder downloads only the projected canonical Linux binaries, verifies +that handoff, uses Melange and apko from the consumer's lock, verifies the +resulting layout and SBOMs, and uploads `oci-image` for seven days. It has no +registry or release credential. + +### `publish-oci-image.yml` + +The OCI publisher runs on `ubuntu-24.04` with a 15-minute timeout and a +repository-wide concurrency group. + +| Input | Type | Required | Default | +| --- | --- | --- | --- | +| `artifact-id` | string | Yes | None | +| `artifact-digest` | string | Yes | None | +| `image-digest` | string | Yes | None | +| `publish-image` | boolean | No | `false` | + +| Output | Contract | +| --- | --- | +| `image-name` | `ghcr.io//` | +| `image-reference` | Digest-pinned image reference; empty when disabled. | +| `image-digest` | Verified index digest in either mode. | +| `provenance-attestation-url` | GitHub provenance URL; empty when disabled. | +| `amd64-sbom-attestation-url` | GitHub amd64 SBOM URL; empty when disabled. | +| `arm64-sbom-attestation-url` | GitHub arm64 SBOM URL; empty when disabled. | + +Disabled publication validates the artifact and runs OCI preparation in dry-run +mode without logging in, pushing, signing, attesting, or applying tags. Enabled +publication prepares and recursively signs the digest, creates one index +provenance and two platform SBOM attestations through `actions/attest`, and then +finalizes tags from fresh registry state. + +### `publish-github-release.yml` + +The GitHub publisher runs on `ubuntu-24.04` with a 10-minute timeout. + +| Input | Type | Required | Default | Contract | +| --- | --- | --- | --- | --- | +| `artifact-id` | string | Yes | None | ID from `go-pre-publish.yml`. | +| `artifact-digest` | string | Yes | None | Expected Actions artifact digest. | +| `checksum-signing-workflow-ref` | string | Yes | None | `owner/repository/.github/workflows/file@revision`; the workflow adds `https://github.com/`. | +| `release-app-client-id` | string | Yes | None | Adopter-owned App client ID. | +| `publish-release` | boolean | No | `true` | Make the verified draft public. | +| `require-oci-image` | boolean | No | `false` | Require a successful digest-pinned caller image before publication. | +| `oci-image-reference` | string | No | Empty | Required `ghcr.io/@sha256:` when the preceding condition applies. | + +Secret: + +| Secret | Required | Contract | +| --- | --- | --- | +| `release-app-private-key` | Yes | Adopter-owned App private key used to mint a short-lived `contents: write` token. | + +Outputs: + +| Output | Contract | +| --- | --- | +| `attestation-url` | GitHub build-provenance attestation URL. | +| `release-url` | URL of the populated draft or public release. | + +The publisher verifies the artifact metadata and download digest, removes the +two package-manager controls, verifies the closed bundle and exact Cosign +identity, attests subjects from `checksums.txt`, and then reconciles the matching +draft. With `publish-release: false`, it keeps and verifies draft state. With +publication enabled, undrafting is its last mutation. + +### `publish-homebrew.yml` + +| Input | Type | Required | Default | +| --- | --- | --- | --- | +| `artifact-id` | string | Yes | None | +| `artifact-digest` | string | Yes | None | +| `checksum-signing-workflow-ref` | string | Yes | None | +| `tap` | string | No | Empty | +| `cask` | string | No | Empty | +| `release-app-client-id` | string | No | Empty | +| `publish-homebrew` | boolean | No | `false` | + +`release-app-private-key` is optional at interface level and required only when +publication is enabled. A disabled call skips before validation, token creation, +or a tap request. + +Outputs are `branch`, `pull-request-url`, and `state`. State is `created`, +`open`, or `published`. The deterministic branch is +`release//v`. The workflow accepts exactly one +`homebrew/Casks/.rb` control, verifies the underlying signed release +bundle, mints a tap-scoped App token, and opens or reconciles a pull request. It +never writes the default branch, force-updates, merges, or enables auto-merge. + +### `publish-scoop.yml` + +| Input | Type | Required | Default | +| --- | --- | --- | --- | +| `artifact-id` | string | Yes | None | +| `artifact-digest` | string | Yes | None | +| `checksum-signing-workflow-ref` | string | Yes | None | +| `bucket` | string | No | Empty | +| `manifest` | string | No | Empty | +| `release-app-client-id` | string | No | Empty | +| `publish-scoop` | boolean | No | `false` | + +`release-app-private-key` is conditional in the same way as the Homebrew +secret. Outputs are `branch`, `pull-request-url`, and `state`, with the same +three states. The deterministic branch is +`release//v`. The workflow accepts exactly one +`scoop/.json` control and writes `.json` at the bucket root +through a reviewed pull request. + +### `request-package-repository.yml` + +| Input | Type | Required | Default | +| --- | --- | --- | --- | +| `package-repository-owner` | string | Yes | None | +| `package-repository-name` | string | Yes | None | +| `release-app-client-id` | string | No | Empty | +| `publish-package-repository` | boolean | No | `false` | + +`release-app-private-key` is required only when enabled. The workflow mints a +token scoped to the named central repository and sends a `package-release` +`repository_dispatch` containing only the producer `owner/name` and exact tag. +It has no outputs and receives no R2 or aggregate signing credential. + +### `publish-package-repository.yml` + +The central receiver runs on `ubuntu-24.04` with a 45-minute timeout, selects +environment `packages-production`, and serializes every production write. + +| Input | Type | Required | Default | +| --- | --- | --- | --- | +| `repository` | string | Yes | None | +| `tag` | string | Yes | None | +| `config-path` | string | No | `.config/package-repository.yaml` | +| `keys-path` | string | No | `.config` | +| `cloudflare-account-id` | string | Yes | None | +| `r2-bucket` | string | Yes | None | + +The selected environment defines: + +- `R2_ACCESS_KEY_ID`; +- `R2_SECRET_ACCESS_KEY`; +- `PACKAGE_REPOSITORY_GPG_PRIVATE_KEY`; +- `PACKAGE_REPOSITORY_GPG_PASSPHRASE`; and +- `PACKAGE_REPOSITORY_APK_PRIVATE_KEY`. + +The receiver checks out the caller's policy and public keys, builds the CLI from +the reusable workflow source, materializes aggregate signing keys, and performs +one convergent repository publication. + +## Producer repository contract + +The producer supplies: + +- one Go module and command; +- `.goreleaser.yaml` schema version 2; +- `mise.toml` and `mise.lock` with Go, GoReleaser, Syft, Cosign, GitHub CLI, + Melange, and apko; +- `melange.yaml` and `apko.yaml`; +- Release Please config and manifest; and +- the two caller workflows. + +The Go profile invokes: + +```text +goreleaser release --clean --skip=publish +``` + +A compatible GoReleaser configuration: + +- builds static Darwin, Linux, and Windows binaries on `amd64` and `arm64`; +- archives Darwin and Linux as `tar.gz` and Windows as ZIP; +- reuses each canonical Linux binary for DEB, RPM, and APK; +- emits archive and package SBOMs; +- writes `checksums.txt` and `checksums.txt.sigstore.json`; +- disables GoReleaser changelog and release publication; +- uses `skip_upload: true` for Homebrew and Scoop controls; and +- keeps nFPM ID `release` when optional native signing is enabled. + +Staging requires exactly one executable static Linux binary for `amd64` and one +for `arm64`, both with the same filename. It writes the digest-bound +`oci-build-inputs.json` projection. + +Melange packages the projected files for `x86_64` and `aarch64` without +compiling them. apko composes one index for `amd64` and `arm64`, runs as numeric +user and group `65532`, installs one `/usr/bin/` entrypoint, and carries +source, version, revision, title, description, and license annotations. + +## Actions artifacts and public assets + +`release-assets` includes: + +```text +dist/*.tar.gz +dist/*.zip +dist/*.deb +dist/*.rpm +dist/*.apk +dist/*.sbom.json +dist/checksums.txt +dist/checksums.txt.sigstore.json +dist/homebrew/Casks/*.rb +dist/scoop/*.json +``` + +For one cask and one Scoop manifest, the maintained example has 28 Actions +artifact files. The two package-manager controls are not entries in +`checksums.txt`, attestation subjects, or GitHub Release assets. + +The public GitHub Release contains 26 files: + +- six platform archives; +- six native packages; +- twelve SBOMs; +- `checksums.txt`; and +- `checksums.txt.sigstore.json`. + +Every checksum entry is a flat, unique filename matching +`[A-Za-z0-9][A-Za-z0-9._+-]*`. The manifest cannot name either control file. +The extracted release directory must contain exactly the checksummed payloads +and the two controls. Symlinks, directories, irregular files, missing payloads, +unexpected entries, and digest mismatches fail closed. + +GitHub attestation subjects are the 24 payloads listed in `checksums.txt`. The +manifest and Cosign bundle are distribution controls, not subjects. + +`oci-build-inputs` contains `artifacts.json`, `oci-build-inputs.json`, and the +two canonical binary trees. `oci-image` contains the locked apko configuration, +signed ephemeral APK repositories and public key, OCI layout, +`image-digest.txt`, and one SPDX SBOM per architecture. The ephemeral Melange +private key is not uploaded. + +## Signer identities + +Checksum verification requires: + +| Field | Required value | +| --- | --- | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@` for external consumers. | +| OIDC issuer | `https://token.actions.githubusercontent.com` | +| Signed blob | `checksums.txt` | +| Bundle | `checksums.txt.sigstore.json` | + +The maintained public GitHub Release and OCI attestation verification commands +constrain the reusable signer workflow, its full signer digest, the producer +repository, `refs/tags/`, and GitHub-hosted runners. They do not pass +a source-digest constraint. + +Native package attestation verification instead constrains the reusable signer +workflow, producer repository, tag ref, resolved producer commit, and +GitHub-hosted runners. The native policy has no signer-digest field; its +`checksum_identity` separately pins the checksum signer to the full release-unit +SHA. + +The setup action verifies a released `release-cli` archive against the +`meigma/release` repository, GitHub publisher workflow path, and GitHub-hosted +runner. It also verifies the selected release checksum and the binary's version +and protocol stamps, but does not pass signer-digest, source-ref, or +source-digest constraints. + +OCI signatures constrain the exact reusable OCI publisher identity and OIDC +issuer. OCI GitHub attestations exist in the producer's attestation store and as +registry referrers. A Cosign signature and a GitHub attestation are separate +requirements. + +## Publication contracts and states + +### GitHub Release + +The publisher requires the tag to resolve to the workflow commit and exactly one +release to carry that tag. The normal mutation path begins from a draft. It +refuses an existing asset outside the expected closed set, uploads expected +names with clobber semantics, waits for GitHub's uploaded state and SHA-256 +digest, and makes the draft public last. + +| State | Result | +| --- | --- | +| Draft missing or duplicated | Failure; no release is created. | +| Draft-only success | Expected assets converge and the release remains draft. | +| Publish success | Expected assets converge, then the same release becomes public. | +| Public exact match during publish-enabled retry | Success without mutation. | +| Any public state during draft-only operation | Indeterminate. | +| Public mismatched asset state | Indeterminate; no re-draft or deletion. | + +### OCI image + +Preparation validates layout bytes and tag state, pushes by digest, verifies the +pushed manifests, and recursively signs the index without creating a tag. +GitHub creates one provenance and two SBOM attestations. Finalization re-reads +registry state, rejects drift, recomputes the plan, applies tags serially, and +verifies each resolution. + +`publish-image: false` performs validation and planning only. `image-reference` +remains empty. Failed prepare or attestation can leave untagged content or +partial trust metadata. Failed finalization can leave a prefix of planned tags, +but every applied candidate tag already names signed and attested content. + +GHCR package visibility is independent of source repository visibility. The +supported delivery state is public; an organization owner performs the current +one-time visibility change through the package settings UI when necessary. + +### Homebrew and Scoop + +Both publishers reconcile one generated control against a deterministic branch. +They accept an existing branch only when it has the observed default head as its +sole parent, changes only the expected path, and contains the exact generated +bytes. + +| State | Meaning | +| --- | --- | +| `created` | A new non-draft pull request was opened. | +| `open` | The one matching pull request remains open. | +| `published` | Exact generated bytes are already on the default branch. | + +A different control at the same or a newer version, multiple matching pull +requests, a closed unmerged pull request, or unrelated branch changes are +conflicts. The publishers do not merge, approve, auto-merge, force-update, or +delete refs. + +### Native repository + +A request contains lowercase producer `owner/name` and one stable tag. The +release must be public and closed. Each accepted native package has: + +1. a checksum entry and matching GitHub asset digest; +2. a GitHub attestation matching the explicit shared signer, producer, source + tag, and source commit; +3. policy-matching package name, version, architecture, and format; and +4. a valid producer signature for RPM and APK. + +The publisher regenerates the complete repository from the incoming release and +all existing immutable package objects. It installs the exact version locally, +uploads non-root objects before commit roots, then installs from the public +origin. + +The commit roots are: + +- `apt/dists/stable/InRelease`; +- `rpm/stable//repodata/repomd.xml`; and +- `apk/stable/main//APKINDEX.tar.gz`. + +Packages, public keys, and APT by-hash objects are immutable with one-year cache +headers. Indexes, signatures, and other replaceable metadata use `no-store`. +Publication never deletes an object. Its result state is `published` when it +writes at least one object and `unchanged` when every generated object matches. + +## Native package policy schema + +The parser accepts one YAML document no larger than 64 KiB. It rejects unknown +fields, YAML aliases, multiple documents, duplicate producers, duplicate +package ownership, duplicate published key names, unsupported channels, and +malformed paths. + +Template; replace the origin and revision before use: + +```yaml +channel: stable +origin: https://packages.example.com +keys: + apt: + source: keys/repository.asc + published: apt-repository-001.asc + rpm: + source: keys/repository.asc + published: rpm-repository-001.asc + apk: + source: keys/repository-apk.rsa.pub + published: apk-index-001.rsa.pub +producers: + - repository: acme/widget + packages: + - widget + checksum_identity: https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + attestation_signer: meigma/release/.github/workflows/publish-github-release.yml + rpm_key: + source: keys/widget-rpm.asc + published: widget-rpm-001.asc + apk_key: + source: keys/widget-apk.rsa.pub + published: widget-apk-001.rsa.pub +``` + +Top-level fields: + +| Field | Contract | +| --- | --- | +| `channel` | Exactly `stable`. | +| `origin` | Public absolute HTTPS root without path prefix, query, fragment, or credentials. | +| `keys.apt` | Aggregate APT metadata public key. | +| `keys.rpm` | Aggregate RPM metadata public key; may share the APT source. | +| `keys.apk` | Aggregate APK index public key. | +| `producers` | Nonempty producer and package allowlist. | + +Producer fields: + +| Field | Contract | +| --- | --- | +| `repository` | Unique lowercase GitHub `owner/name`. | +| `packages` | Nonempty unique package ownership list. | +| `checksum_identity` | Exact immutable GitHub workflow certificate URL whose filename ends in `.yml` or `.yaml` and whose ref is one full lowercase SHA. | +| `attestation_signer` | Exact `owner/repository/.github/workflows/.yml` or `.yaml`, without URL or ref. | +| `rpm_key` | Producer RPM package-signing public key. | +| `apk_key` | Producer APK package-signing public key. | + +Each key has a confined slash-separated `source` beneath the keys root and a +unique flat `published` filename beneath public `keys/`. + +Public architecture names are: + +| Normalized | APT | RPM | APK | +| --- | --- | --- | --- | +| `amd64` | `amd64` | `x86_64` | `x86_64` | +| `arm64` | `arm64` | `aarch64` | `aarch64` | + +## Retry classes and manual boundaries + +| Operation | Retry or replay contract | +| --- | --- | +| Actions artifact metadata | Four attempts total; waits of 1, 2, and 4 seconds for rate limits and HTTP `5xx`. | +| GitHub API publication operations | Up to four attempts for retryable failures, with 1, 2, and 4 second waits. | +| Missing draft | Up to 24 observations; a 5-second wait follows every miss, including the final exhausted miss. | +| Incomplete GitHub asset set | Up to 12 observations; a 1-second wait follows every incomplete result, including the final exhausted result. | +| Homebrew and Scoop repository operations | Up to four attempts; each write failure is followed by a fresh-state read. | +| OCI registry operations | Preparation retries blob pushes and digest verification. Finalization retries tag commits and postcondition reads. Planning and preparation's initial tag collection do not use that helper. | +| Native repository replay | The release-policy layer has no retry loop. A new invocation converges from current R2 objects: matching immutable objects skip and replaceable metadata regenerates. | + +Authentication failures, invalid configuration, absent Actions handoff +artifacts, missing required local inputs, tag/commit mismatches, unexpected +release assets, digest conflicts, immutable OCI tag conflicts, corrupt channel +state, conflicting destination branches, invalid native signatures, and +conflicting immutable R2 objects are not transient retry classes. A missing +draft and incomplete expected asset set use the polling contracts above; an +absent R2 object is uploaded during convergence. + +Manual inspection is required after an uncertain undraft, unexpected GitHub +asset, OCI registry drift, conflicting Homebrew/Scoop destination state, or +immutable R2 conflict. A public release has no automated rollback. + +## Unsupported cases + +The current release system does not support: + +- languages other than the Go producer profile; +- more than one application, binary, or image entrypoint per repository; +- monorepo component tags or scoped versions; +- prereleases or build metadata; +- CGO-dependent or dynamically linked commands; +- binary architectures outside Darwin/Linux/Windows `amd64` and `arm64`; +- OCI registries, namespaces, or names other than the caller's GHCR path; +- mutable exact OCI version tags; +- automatic GHCR visibility changes; +- Homebrew formula publication; +- Scoop manifests below a `bucket/` directory; +- direct publisher merges or auto-merge; +- producer access to R2 or aggregate package-repository signing keys; +- package-repository channels other than `stable`; +- object deletion, pruning, or in-place replacement of immutable package paths; +- automatic creation of a missing draft or deletion of an unexpected asset; +- moving a public tag, re-drafting a public release, or rollback after + publication; or +- automatic repository ruleset, environment, App, key, or credential setup. diff --git a/docs/tutorials/release-a-go-project.md b/docs/tutorials/release-a-go-project.md deleted file mode 100644 index 45ae073..0000000 --- a/docs/tutorials/release-a-go-project.md +++ /dev/null @@ -1,416 +0,0 @@ -# Release a Go project - -In this tutorial, we will turn the copyable example into a working release for a -small Go command. We will first run the complete pipeline with publication -disabled, inspect the draft and workflow artifacts, and then publish through the -same Release Please tag and draft. - -The tutorial uses a command named `hello-release`. For an existing project with -different source and release policies, use [Configure GitHub Releases](../how-to/configure-github-releases.md) instead of copying the sample command. - -## Prerequisites - -You need: - -- a GitHub repository named `hello-release`, with `main` as its default branch, - and a local checkout of that repository; -- a local checkout of `meigma/release` containing `examples/go-release/`; -- permission to create and merge pull requests in the `hello-release` - repository; -- help from a Meigma organization owner to grant the Release App and - organization Actions credentials access to the repository; -- GitHub Actions enabled, with a policy that permits the pinned workflows and - actions used by the example; and -- Git, GitHub CLI, `mise`, and `jq` installed locally, with GitHub CLI - authenticated for the repository. - -Start in the `hello-release` checkout and record the consumer repository and -example path: - -```bash -export RELEASE_EXAMPLE=/absolute/path/to/meigma-release/examples/go-release -export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" -test "${REPOSITORY#*/}" = hello-release -test "$DEFAULT_BRANCH" = main -printf 'Consumer: %s\nExample: %s\n' "$REPOSITORY" "$RELEASE_EXAMPLE" -``` - -The final command prints the `OWNER/hello-release` repository and the absolute -path to `examples/go-release`. - -## Copy the example - -The tutorial starts with paths that do not already contain release -configuration. Confirm that before copying: - -```bash -for path in \ - .github/workflows/release-please.yml \ - .github/workflows/release.yml \ - .goreleaser.yaml \ - apko.yaml \ - melange.yaml \ - .release-please-manifest.json \ - release-please-config.json \ - mise.toml \ - mise.lock \ - go.mod \ - cmd/hello-release; do - test ! -e "$path" -done -``` - -The command exits without output. Now copy the release files and sample -command: - -```bash -mkdir -p .github/workflows cmd -cp "$RELEASE_EXAMPLE/.github/workflows/release-please.yml" .github/workflows/ -cp "$RELEASE_EXAMPLE/.github/workflows/release.yml" .github/workflows/ -cp "$RELEASE_EXAMPLE/.goreleaser.yaml" . -cp "$RELEASE_EXAMPLE/apko.yaml" . -cp "$RELEASE_EXAMPLE/melange.yaml" . -cp "$RELEASE_EXAMPLE/.release-please-manifest.json" . -cp "$RELEASE_EXAMPLE/release-please-config.json" . -cp "$RELEASE_EXAMPLE/mise.toml" . -cp "$RELEASE_EXAMPLE/mise.lock" . -cp "$RELEASE_EXAMPLE/go.mod" . -cp -R "$RELEASE_EXAMPLE/cmd/example" cmd/hello-release -``` - -Check every copied path: - -```bash -for path in \ - .github/workflows/release-please.yml \ - .github/workflows/release.yml \ - .goreleaser.yaml \ - apko.yaml \ - melange.yaml \ - .release-please-manifest.json \ - release-please-config.json \ - mise.toml \ - mise.lock \ - go.mod \ - cmd/hello-release/main.go; do - test -f "$path" - printf 'copied %s\n' "$path" -done -``` - -You see one `copied` line for each of the eleven paths. - -## Adapt the Go command and packages - -Install the locked tools, then set the module path to the GitHub repository: - -```bash -mise install --locked -mise exec -- go mod edit -module "github.com/$REPOSITORY" -head -n 1 go.mod -``` - -`mise install` reports that the locked tools are installed without changing -their requested versions. The final command prints: - -```text -module github.com/OWNER/hello-release -``` - -Replace `OWNER` with the owner shown in `$REPOSITORY`. - -Edit `cmd/hello-release/main.go`. Replace the two user-facing occurrences of -`example` with `hello-release`. Keep the `version` and `commit` variables: the -copied GoReleaser configuration sets both at link time. - -Edit `.goreleaser.yaml` so its project, build, archive, nFPM, and binary values read: - -```yaml -project_name: hello-release - -builds: - - id: hello-release - main: ./cmd/hello-release - binary: hello-release - -archives: - - id: hello-release - ids: - - hello-release - -nfpms: - - id: hello-release - ids: - - hello-release - vendor: Meigma - homepage: https://github.com/OWNER/hello-release - maintainer: Meigma - description: Hello release tutorial command. - license: LicenseRef-Proprietary - -These lines replace the corresponding `example` values; keep the remaining -build, archive, nFPM, checksum, SBOM, signing, changelog, and release settings -from the example. Replace `OWNER` with the owner shown in `$REPOSITORY`. In -particular, keep: - -```yaml -changelog: - disable: true - -release: - disable: true -``` - -Release Please will own the release notes and initial draft. During the -producer job, `release-cli stage --profile go` invokes exactly -`goreleaser release --clean --skip=publish`; `release.disable: true` is the -second control that prevents GoReleaser publication. - -In `release-please-config.json`, replace the package name `example` with -`hello-release`. Keep the initial version at `0.1.0` and the manifest version at -`0.0.0` for this first release. - -Run the sample command: - -```bash -mise exec -- go run ./cmd/hello-release --version -``` - -The command prints: - -```text -hello-release dev (none) -``` - -## Adapt the packages and image - -Edit `melange.yaml` for the same command: - -```yaml -package: - name: hello-release - description: Hello release tutorial command. - vendor: Meigma - homepage: https://github.com/OWNER/hello-release - maintainer: Meigma - copyright: - - license: LicenseRef-Proprietary - -pipeline: - - runs: | - install -Dm755 -o 0 -g 0 application "${{targets.destdir}}/usr/bin/hello-release" -``` - -Keep the version variable, target architectures, Wolfi repository and keyring, -and package environment from the example. - -Edit `apko.yaml` so the package, entrypoint, and annotations match the command: - -```yaml -contents: - packages: - - alpine-release - - ca-certificates-bundle - - hello-release - -entrypoint: - command: /usr/bin/hello-release - -annotations: - org.opencontainers.image.title: hello-release - org.opencontainers.image.description: Hello release tutorial command. - org.opencontainers.image.source: https://github.com/OWNER/hello-release - org.opencontainers.image.licenses: LicenseRef-Proprietary -``` - -Keep the `OWNER` replacement consistent with the nFPM and Melange package metadata. Keep the nonroot account, -architectures, certificate settings, and environment from the example. - -Validate the completed release configuration: - -```bash -mise exec -- goreleaser check -mise exec -- go list ./cmd/... -printf 'https://github.com/%s\n' "$REPOSITORY" -``` - -`goreleaser check` exits successfully. `go list` prints -`github.com/OWNER/hello-release/cmd/hello-release`, and the final command prints -the source URL now present in `apko.yaml`. - -## Grant the release identities access - -Complete the App installation and organization credential steps in -[Configure GitHub Releases](../how-to/configure-github-releases.md#1-grant-the-release-app-access): - -1. Give the Meigma Release App selected-repository access to `hello-release`. -2. Give the organization variable `MEIGMA_RELEASE_APP_CLIENT_ID` and organization - secret `MEIGMA_RELEASE_APP_PRIVATE_KEY` selected-repository access to the - same repository. -3. If a protected-tag ruleset covers `v*`, grant its bypass to the Release App. - -The App installation, variable, and secret settings each show -`OWNER/hello-release` in their selected repository list. The private key never -appears in the repository. - -Also confirm the image prerequisites in -[Configure OCI image publication](../how-to/configure-oci-images.md#prerequisites). -The release caller grants the publisher `packages: write`; the organization must -have GitHub Packages enabled for the workflow to use that permission. - -## Merge the disabled-publication configuration - -The copied caller begins in rehearsal mode. Confirm both controls before -committing: - -```bash -grep -F 'publish-image: false' .github/workflows/release.yml -grep -F 'publish-release: false' .github/workflows/release.yml -``` - -Both lines print. Commit the copied and adapted files on a branch, submit them -for review, and merge them through the repository's normal pull request flow: - -```bash -git switch -c feat/add-release-workflows -git add \ - .github/workflows/release-please.yml \ - .github/workflows/release.yml \ - .goreleaser.yaml \ - apko.yaml \ - melange.yaml \ - .release-please-manifest.json \ - release-please-config.json \ - mise.toml \ - mise.lock \ - go.mod \ - cmd/hello-release/main.go -git commit -m 'feat: add hello release command' -git push -u origin HEAD -gh pr create --fill -``` - -GitHub prints the new pull request URL. After its required checks pass, merge it -with the repository's normal reviewed process. The default branch then contains -the command and both release workflows. - -## Run the unpublished rehearsal - -Treat the first `v0.1.0` candidate as a controlled, unpublished rehearsal tag. -Do not create a draft or push a tag yourself. Release Please owns the release -notes, tag, and initial draft. - -Follow [Configure a draft-only run, create the candidate, and inspect the -draft](../how-to/rehearse-and-recover-github-releases.md#1-configure-a-draft-only-run). -Stop after section 3 of that guide. It supplies the exact Release Please, tag, -run-selection, and draft-query commands. Keep the `REPOSITORY`, `TAG`, -`RELEASE_RUN_ID`, and `RELEASE_ID` values from those steps. - -At the end of the rehearsal: - -- the Release workflow is green; -- the GitHub Release for `$TAG` is still a draft with 26 assets; -- the `release-assets`, `oci-build-inputs`, and `oci-image` workflow artifacts - exist; and -- no image tag has been written to GHCR. - -Open the `release-assets` job log. Its `Stage Go release artifacts` step contains -GoReleaser progress followed by release bundle staging. There is no separate -GoReleaser build step in the workflow: `release-cli stage --profile go` owns the -build and validation. - -The `oci-publish` job runs the prepare command in dry-run mode. Its GHCR login, -attestation, and finalize steps are skipped. The `github-release` job populates -and verifies the draft but does not undraft it. - -## Inspect the workflow artifacts - -Download the authoritative release bundle and OCI image from the exact -rehearsal run: - -```bash -export INSPECT_DIR="rehearsal-${TAG#v}" -test ! -e "$INSPECT_DIR" -mkdir -p "$INSPECT_DIR/release-assets" "$INSPECT_DIR/oci-image" -gh run download "$RELEASE_RUN_ID" \ - --repo "$REPOSITORY" \ - --name release-assets \ - --dir "$INSPECT_DIR/release-assets" -gh run download "$RELEASE_RUN_ID" \ - --repo "$REPOSITORY" \ - --name oci-image \ - --dir "$INSPECT_DIR/oci-image" -``` - -Check the release bundle: - -```bash -test "$(find "$INSPECT_DIR/release-assets" -maxdepth 1 -type f -name '*.sbom.json' | wc -l)" -eq 12 -test "$(find "$INSPECT_DIR/release-assets" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) | wc -l)" -eq 6 -test "$(find "$INSPECT_DIR/release-assets" -maxdepth 1 -type f \( -name '*.deb' -o -name '*.rpm' -o -name '*.apk' \) | wc -l)" -eq 6 -test -s "$INSPECT_DIR/release-assets/checksums.txt" -test -s "$INSPECT_DIR/release-assets/checksums.txt.sigstore.json" -printf 'release bundle complete\n' -``` - -The commands print `release bundle complete`. - -Check the OCI index and its two platforms: - -```bash -test -s "$INSPECT_DIR/oci-image/layout/index.json" -test -s "$INSPECT_DIR/oci-image/image-digest.txt" -test -s "$INSPECT_DIR/oci-image/sboms/sbom-x86_64.spdx.json" -test -s "$INSPECT_DIR/oci-image/sboms/sbom-aarch64.spdx.json" -jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture)"' \ - "$INSPECT_DIR/oci-image/layout/index.json" | - sort -``` - -The final command prints: - -```text -linux/amd64 -linux/arm64 -``` - -The draft and artifacts now show what the enabled run will publish without -having changed the public release or GHCR tags. - -## Enable publication - -Resume with the same still-unpublished tag and draft by following -[Resume through the same tag and draft](../how-to/rehearse-and-recover-github-releases.md#4-resume-through-the-same-tag-and-draft). -That procedure changes both publication inputs to `true` in one reviewed commit -and selects the new run for the exact tag and enabling commit. It also preserves -Release Please ownership of the existing draft and release notes. - -In the successful resume run, the OCI publisher prepares and signs the image, -the three GitHub attestation steps complete, and finalization applies the image -tags. The GitHub publisher verifies the release bundle, creates the release -attestation, converges the existing draft assets, and makes the draft public -only after the OCI publisher succeeds. - -Confirm the release state with the command from the rehearsal guide: - -```bash -gh release view "$TAG" \ - --repo "$REPOSITORY" \ - --json tagName,isDraft,isPrerelease,publishedAt,url -``` - -The result reports the same tag, `"isDraft": false`, -`"isPrerelease": false`, a non-null publication time, and a release URL. - -GitHub package visibility follows the organization's package-creation setting. -Complete the visibility check and the signature, attestation, checksum, and -runtime verification in [Configure OCI image publication](../how-to/configure-oci-images.md#6-verify-the-published-image) and [Verify the published release](../how-to/configure-github-releases.md#7-verify-the-published-release). - -## What you learned - -You adapted one release unit, rehearsed it without publication, inspected the -exact artifacts crossing into the publisher jobs, and then published through -the same Release Please tag and draft. - -For the complete interfaces, see the [GitHub Release contract](../reference/github-release-contract.md), [OCI image contract](../reference/oci-image-contract.md), and [`release-cli` contract](../reference/release-cli-contract.md). For the security reasoning behind the job and command boundaries, see [Why release trust is split across workflows and the CLI](../explanation/release-trust-boundaries.md). diff --git a/docs/tutorials/release-your-first-go-application.md b/docs/tutorials/release-your-first-go-application.md new file mode 100644 index 0000000..a31989d --- /dev/null +++ b/docs/tutorials/release-your-first-go-application.md @@ -0,0 +1,349 @@ +# Release your first Go application + +In this tutorial, you will release a disposable Go command from `acme/widget`. +You will copy the maintained example, select one immutable release-unit +revision, rehearse against a draft, and then publish the same candidate. + +The tutorial publishes a GitHub Release and a digest-pinned GHCR image. It +leaves Homebrew, Scoop, and native package-repository publication disabled. + +## Before you begin + +Complete [Prepare your GitHub organization](../how-to/prepare-your-github-organization.md) +for a disposable repository. You need: + +- an adopter-owned GitHub App installed on `acme/widget`; +- the App client ID and private key available to that repository as Actions + variable `MEIGMA_RELEASE_APP_CLIENT_ID` and secret + `MEIGMA_RELEASE_APP_PRIVATE_KEY`; +- any `v*` tag rules configured to let the App create tags and to let an + authorized operator move this unpublished rehearsal tag; +- GitHub Actions and GitHub Packages enabled; +- Git, GitHub CLI, mise, ORAS, Cosign, Docker, and `jq`; and +- a local checkout of this repository and an empty checkout of `acme/widget`. + +Use a disposable repository because resuming the rehearsal moves an +unpublished tag. Never use this procedure to move a published tag. + +Start in the empty `acme/widget` checkout: + +```bash +export REPOSITORY=acme/widget +export RELEASE_EXAMPLE=/absolute/path/to/release/examples/go-release +test "$(gh repo view --json nameWithOwner --jq .nameWithOwner)" = "$REPOSITORY" +test -d "$RELEASE_EXAMPLE" +``` + +## Copy the maintained example + +Copy the release files and sample command without copying the example README: + +```bash +mkdir -p .github cmd +cp -R "$RELEASE_EXAMPLE/.github" . +cp "$RELEASE_EXAMPLE/.goreleaser.yaml" . +cp "$RELEASE_EXAMPLE/apko.yaml" . +cp "$RELEASE_EXAMPLE/melange.yaml" . +cp "$RELEASE_EXAMPLE/.release-please-manifest.json" . +cp "$RELEASE_EXAMPLE/release-please-config.json" . +cp "$RELEASE_EXAMPLE/mise.toml" . +cp "$RELEASE_EXAMPLE/mise.lock" . +cp "$RELEASE_EXAMPLE/go.mod" . +cp -R "$RELEASE_EXAMPLE/cmd/example" cmd/widget +``` + +Select the latest published `meigma/release` release and resolve its tag to one +full commit SHA: + +```bash +export RELEASE_TAG="$(gh api repos/meigma/release/releases/latest --jq .tag_name)" +export RELEASE_REVISION="$(gh api "repos/meigma/release/commits/$RELEASE_TAG" --jq .sha)" +[[ "$RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] +test "$(gh api "repos/meigma/release/commits/$RELEASE_REVISION" --jq .sha)" = \ + "$RELEASE_REVISION" +printf 'Release unit: %s at %s\n' "$RELEASE_TAG" "$RELEASE_REVISION" +``` + +Replace every `REPLACE_WITH_RELEASE_COMMIT_SHA` occurrence in +`.github/workflows/release.yml` with `$RELEASE_REVISION`. Then confirm that the +caller contains only that immutable release-unit revision: + +```bash +! grep -R 'REPLACE_WITH_RELEASE_COMMIT_SHA' .github/workflows +refs="$(grep -Eo '@[0-9a-f]{40}' .github/workflows/release.yml | sort -u)" +test "$refs" = "@$RELEASE_REVISION" +``` + +This one SHA selects every reusable workflow, every checksum signer identity, +and the `release-cli` installed by those workflows. + +## Adapt the application + +Edit the copied files as follows: + +1. Set the module in `go.mod` to `github.com/acme/widget`. +2. Replace the user-facing command name `example` with `widget` in + `cmd/widget/main.go`. Keep the `version` and `commit` variables. +3. In `.goreleaser.yaml`, replace the project, build, archive, package, cask, + and Scoop names with `widget`; set `main` to `./cmd/widget`; set the homepage + and release URL owner and repository to `acme/widget`; and replace the + example organization metadata. +4. In `release-please-config.json`, set `package-name` to `widget`. +5. In `melange.yaml`, set the package and installed binary to `widget`, and + replace the example organization metadata. +6. In `apko.yaml`, set the package and entrypoint to `widget` and use + `https://github.com/acme/widget` as the source annotation. + +Keep these release controls unchanged: + +```yaml +changelog: + disable: true + +release: + disable: true +``` + +Also keep these caller inputs disabled: + +```yaml +publish-image: false +publish-release: false +publish-homebrew: false +publish-scoop: false +publish-package-repository: false +``` + +The adopter-owned destination placeholders can remain while their publishers +are disabled. Homebrew and Scoop values affect only controls in the temporary +Actions artifact; those controls are excluded from the GitHub Release. The +package-repository request job is skipped. + +Install the locked tools and check the adapted command: + +```bash +mise install --locked +mise exec -- goreleaser check +mise exec -- go run ./cmd/widget --version +``` + +The command prints `widget dev (none)`. + +## Merge the rehearsal configuration + +Commit the application and release configuration on a branch, submit it for +review, and merge it through the repository's normal pull request process: + +```bash +git switch -c feat/add-widget-release +git add . +git commit -m 'feat: add widget command' +git push -u origin HEAD +gh pr create --fill +``` + +After the pull request is merged, dispatch Release Please: + +```bash +export DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" +gh workflow run release-please.yml --repo "$REPOSITORY" --ref "$DEFAULT_BRANCH" +gh run list --repo "$REPOSITORY" --workflow release-please.yml --limit 5 +``` + +The run opens or updates one Release Please pull request. Find it, review the +version and changelog, and merge it after its required checks pass: + +```bash +export RELEASE_PR="$(gh pr list \ + --repo "$REPOSITORY" \ + --label 'autorelease: pending' \ + --json number \ + --jq '.[0].number')" +test -n "$RELEASE_PR" +gh pr view "$RELEASE_PR" --repo "$REPOSITORY" +gh pr merge "$RELEASE_PR" --repo "$REPOSITORY" --squash --delete-branch +export VERSION_SHA="$(gh pr view "$RELEASE_PR" \ + --repo "$REPOSITORY" \ + --json mergeCommit \ + --jq .mergeCommit.oid)" +[[ "$VERSION_SHA" =~ ^[0-9a-f]{40}$ ]] +export VERSION_RUNS='[]' +until test "$(jq length <<<"$VERSION_RUNS")" -gt 0; do + export VERSION_RUNS="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release-please.yml \ + --branch "$DEFAULT_BRANCH" \ + --commit "$VERSION_SHA" \ + --event push \ + --limit 100 \ + --json databaseId)" + test "$(jq length <<<"$VERSION_RUNS")" -gt 0 || sleep 2 +done +test "$(jq length <<<"$VERSION_RUNS")" -eq 1 +export VERSION_RUN_ID="$(jq -r '.[0].databaseId' <<<"$VERSION_RUNS")" +gh run watch "$VERSION_RUN_ID" --repo "$REPOSITORY" --compact --exit-status +``` + +Release Please creates the stable tag and matching draft. For the unchanged +initial version, record the candidate: + +```bash +export TAG=v0.1.0 +git fetch origin "refs/tags/$TAG:refs/tags/$TAG" +export TAG_SHA="$(git rev-list -n 1 "$TAG")" +[[ "$TAG_SHA" =~ ^[0-9a-f]{40}$ ]] +export RELEASE_RUNS='[]' +until test "$(jq length <<<"$RELEASE_RUNS")" -gt 0; do + export RELEASE_RUNS="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$TAG_SHA" \ + --event push \ + --limit 100 \ + --json databaseId)" + test "$(jq length <<<"$RELEASE_RUNS")" -gt 0 || sleep 2 +done +test "$(jq length <<<"$RELEASE_RUNS")" -eq 1 +export RELEASE_RUN_ID="$(jq -r '.[0].databaseId' <<<"$RELEASE_RUNS")" +gh run watch "$RELEASE_RUN_ID" --repo "$REPOSITORY" --compact --exit-status +``` + +## Inspect the rehearsal + +Find the one draft for the candidate tag: + +```bash +test "$(gh api --paginate --slurp \ + "repos/$REPOSITORY/releases?per_page=100" \ + --jq "[.[][] | select(.tag_name == \"$TAG\")] | length")" -eq 1 +export RELEASE_ID="$(gh api --paginate --slurp \ + "repos/$REPOSITORY/releases?per_page=100" \ + --jq "[.[][] | select(.tag_name == \"$TAG\")][0].id")" +gh api "repos/$REPOSITORY/releases/$RELEASE_ID" \ + --jq '{tag_name, draft, prerelease, assets: [.assets[].name]}' +``` + +The release is a non-prerelease draft with 26 assets: six archives, six native +packages, twelve SBOMs, `checksums.txt`, and +`checksums.txt.sigstore.json`. GHCR has no release tag because +`publish-image` was false. + +Download the two authoritative workflow artifacts from the exact run: + +```bash +mkdir rehearsal-assets rehearsal-image +gh run download "$RELEASE_RUN_ID" \ + --repo "$REPOSITORY" \ + --name release-assets \ + --dir rehearsal-assets +gh run download "$RELEASE_RUN_ID" \ + --repo "$REPOSITORY" \ + --name oci-image \ + --dir rehearsal-image +test -s rehearsal-assets/checksums.txt +test -s rehearsal-assets/checksums.txt.sigstore.json +test -s rehearsal-image/image-digest.txt +test -s rehearsal-image/layout/index.json +jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture)"' \ + rehearsal-image/layout/index.json | sort +``` + +The final command prints `linux/amd64` and `linux/arm64`. + +## Publish the candidate + +Change only `publish-image` and `publish-release` to `true` in +`.github/workflows/release.yml`. Submit and merge that change. Leave Homebrew, +Scoop, and native package-repository publication disabled. + +Fetch the enabling commit and move the still-unpublished candidate tag to it: + +```bash +git fetch origin "$DEFAULT_BRANCH" --tags +export PUBLISH_SHA="$(git rev-parse "origin/$DEFAULT_BRANCH")" +git tag --force "$TAG" "$PUBLISH_SHA" +git push --force origin "refs/tags/$TAG" +``` + +Select and watch the new run by both tag and commit: + +```bash +export PUBLISH_RUNS='[]' +until test "$(jq length <<<"$PUBLISH_RUNS")" -gt 0; do + export PUBLISH_RUNS="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$PUBLISH_SHA" \ + --event push \ + --limit 100 \ + --json databaseId)" + test "$(jq length <<<"$PUBLISH_RUNS")" -gt 0 || sleep 2 +done +test "$(jq length <<<"$PUBLISH_RUNS")" -eq 1 +export PUBLISH_RUN_ID="$(jq -r '.[0].databaseId' <<<"$PUBLISH_RUNS")" +gh run watch "$PUBLISH_RUN_ID" --repo "$REPOSITORY" --compact --exit-status +``` + +Confirm that the same release ID is now public: + +```bash +test "$(gh release view "$TAG" --repo "$REPOSITORY" --json databaseId --jq .databaseId)" = \ + "$RELEASE_ID" +gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json tagName,isDraft,isPrerelease,publishedAt,url +``` + +## Verify the release and image + +Download and verify the public release bundle: + +```bash +mkdir published-assets +gh release download "$TAG" --repo "$REPOSITORY" --dir published-assets +cd published-assets +sha256sum --check checksums.txt +cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@$RELEASE_REVISION" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.txt +gh attestation verify widget_0.1.0_linux_amd64.tar.gz \ + --repo "$REPOSITORY" \ + --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ + --signer-digest "$RELEASE_REVISION" \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +cd .. +``` + +On macOS, use `shasum -a 256 --check checksums.txt` instead of `sha256sum`. + +Resolve and verify the image by digest: + +```bash +export IMAGE=ghcr.io/acme/widget +gh auth token | oras login ghcr.io \ + --username "$(gh api user --jq .login)" \ + --password-stdin +export DIGEST="$(oras resolve "$IMAGE:${TAG#v}")" +cosign verify \ + --certificate-identity "https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@$RELEASE_REVISION" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$IMAGE@$DIGEST" +gh attestation verify "oci://$IMAGE@$DIGEST" \ + --repo "$REPOSITORY" \ + --bundle-from-oci \ + --signer-workflow meigma/release/.github/workflows/publish-oci-image.yml \ + --signer-digest "$RELEASE_REVISION" \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +docker run --rm "$IMAGE@$DIGEST" --version +``` + +You have now released one Go application through one immutable release unit. +Continue with [Add Homebrew and Scoop](../how-to/add-homebrew-and-scoop.md) or +[Operate a native package repository](../how-to/operate-a-native-package-repository.md) +only after their external repositories, credentials, and review controls exist. diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml index 118f1f0..a628334 100644 --- a/examples/go-release/.github/workflows/release.yml +++ b/examples/go-release/.github/workflows/release.yml @@ -16,27 +16,36 @@ jobs: name: Build release assets if: github.event.deleted == false permissions: - actions: read - # Required because the called workflow verifies the release-cli - # attestation while installing it; a callee cannot exceed this ceiling. attestations: read contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 + uses: meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + sign-and-notarize-macos: false + sign-native-packages: false + secrets: + macos-sign-p12: ${{ secrets.MACOS_SIGN_P12 }} + macos-sign-password: ${{ secrets.MACOS_SIGN_PASSWORD }} + macos-notary-key: ${{ secrets.MACOS_NOTARY_KEY }} + macos-notary-key-id: ${{ secrets.MACOS_NOTARY_KEY_ID }} + macos-notary-issuer-id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + rpm-signing-key: ${{ secrets.RPM_SIGNING_KEY }} + rpm-signing-passphrase: ${{ secrets.RPM_SIGNING_PASSPHRASE }} + apk-signing-key: ${{ secrets.APK_SIGNING_KEY }} + apk-signing-passphrase: ${{ secrets.APK_SIGNING_PASSPHRASE }} oci-image: name: Build OCI image needs: release-assets permissions: actions: read - # Required because the called workflow verifies the release-cli - # attestation while installing it; a callee cannot exceed this ceiling. attestations: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 + uses: meigma/release/.github/workflows/go-oci-build.yml@REPLACE_WITH_RELEASE_COMMIT_SHA with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} + oci-publish: name: Publish OCI image needs: oci-image @@ -47,19 +56,17 @@ jobs: contents: read id-token: write packages: write - uses: meigma/release/.github/workflows/publish-oci-image.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 + uses: meigma/release/.github/workflows/publish-oci-image.yml@REPLACE_WITH_RELEASE_COMMIT_SHA with: artifact-id: ${{ needs.oci-image.outputs.artifact-id }} artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} image-digest: ${{ needs.oci-image.outputs.image-digest }} publish-image: false - github-release: name: Publish GitHub Release needs: - release-assets - - oci-image - oci-publish permissions: actions: read @@ -67,11 +74,11 @@ jobs: attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 + uses: meigma/release/.github/workflows/publish-github-release.yml@REPLACE_WITH_RELEASE_COMMIT_SHA with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA require-oci-image: true oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} @@ -79,14 +86,56 @@ jobs: secrets: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + homebrew-publish: + name: Open Homebrew tap pull request + needs: + - release-assets + - github-release + permissions: + actions: read + attestations: read + contents: read + uses: meigma/release/.github/workflows/publish-homebrew.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + tap: HOMEBREW-OWNER/HOMEBREW-TAP + cask: example + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-homebrew: false + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + + scoop-publish: + name: Open Scoop bucket pull request + needs: + - release-assets + - github-release + permissions: + actions: read + attestations: read + contents: read + uses: meigma/release/.github/workflows/publish-scoop.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@REPLACE_WITH_RELEASE_COMMIT_SHA + bucket: SCOOP-OWNER/SCOOP-BUCKET + manifest: example + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-scoop: false + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + package-repository: name: Request package repository publication needs: github-release permissions: {} - uses: meigma/release/.github/workflows/request-package-repository.yml@583937edadfbae183e49f16df46b98e0b36807ba # v0.1.16 + uses: meigma/release/.github/workflows/request-package-repository.yml@REPLACE_WITH_RELEASE_COMMIT_SHA with: - package-repository-owner: OWNER - package-repository-name: packages + package-repository-owner: PACKAGE-REPOSITORY-OWNER + package-repository-name: PACKAGE-REPOSITORY-NAME release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-package-repository: false secrets: diff --git a/examples/go-release/.goreleaser.yaml b/examples/go-release/.goreleaser.yaml index 4dd1a5f..ee950ea 100644 --- a/examples/go-release/.goreleaser.yaml +++ b/examples/go-release/.goreleaser.yaml @@ -31,6 +31,21 @@ builds: - -X main.commit={{ .FullCommit }} mod_timestamp: "{{ .CommitTimestamp }}" +notarize: + macos: + - enabled: '{{ eq .Env.MACOS_NOTARIZE_ENABLED "true" }}' + ids: + - example + sign: + certificate: "{{ .Env.MACOS_SIGN_P12 }}" + password: "{{ .Env.MACOS_SIGN_PASSWORD }}" + notarize: + issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}" + key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}" + key: "{{ .Env.MACOS_NOTARY_KEY }}" + wait: true + timeout: 20m + archives: - id: example ids: @@ -48,11 +63,11 @@ nfpms: - id: release ids: - example - vendor: Meigma - homepage: https://github.com/meigma/release - maintainer: Meigma - description: Example Go command using the Meigma release workflows. - license: LicenseRef-Proprietary + vendor: Example Organization + homepage: https://github.com/OWNER/REPOSITORY + maintainer: Release Team + description: Example Go command using the reusable release workflows. + license: Apache-2.0 OR MIT formats: - deb - rpm @@ -66,6 +81,35 @@ nfpms: key_file: "{{ .Env.RELEASE_APK_SIGNING_KEY_FILE }}" key_name: example-001 +homebrew_casks: + - name: example + ids: + - example + binaries: + - example + repository: + owner: HOMEBREW-OWNER + name: HOMEBREW-TAP + homepage: https://github.com/OWNER/REPOSITORY + description: Example Go command + license: Apache-2.0 OR MIT + url: + template: "https://github.com/OWNER/REPOSITORY/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + skip_upload: true + +scoops: + - name: example + ids: + - example + repository: + owner: SCOOP-OWNER + name: SCOOP-BUCKET + homepage: https://github.com/OWNER/REPOSITORY + description: Example Go command + license: Apache-2.0 OR MIT + url_template: "https://github.com/OWNER/REPOSITORY/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + skip_upload: true + checksum: name_template: checksums.txt diff --git a/examples/go-release/README.md b/examples/go-release/README.md index 793da4c..9a654ef 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -1,12 +1,17 @@ # Go release example -This directory models a repository named `example` with module `example.com/meigma/release-consumer` and command `./cmd/example`. It contains the minimum source needed to build GitHub Release assets and a multi-architecture OCI image, plus a disabled package-repository dispatch job. It is not a complete CI policy: add the consumer repository's own build, test, review, and branch-protection controls. +This directory is the maintained template for one static Go application in one +repository. It includes the release workflows, a minimal command, GitHub +Release and OCI configuration, Homebrew and Scoop control generation, and a +native package-repository request. -See [Configure GitHub Releases](../../docs/how-to/configure-github-releases.md) for release credential setup and [Configure OCI image publication](../../docs/how-to/configure-oci-images.md) for image configuration, publication, and verification. [Set up the shared package repository](../../docs/how-to/set-up-package-repository.md) defines the central receiver, producer policy, public keys, and signing prerequisites; [Install release-cli from native package repositories](../../docs/how-to/install-release-cli-from-package-repositories.md) shows the resulting client flow. See [Rehearse and recover GitHub Releases](../../docs/how-to/rehearse-and-recover-github-releases.md) before the first publication. Use [Upgrade GitHub Release workflows](../../docs/how-to/upgrade-github-release-workflows.md) to change the pinned revision. The reusable interfaces are defined in the [GitHub Release contract](../../docs/reference/github-release-contract.md), [OCI image contract](../../docs/reference/oci-image-contract.md), and [package repository contract](../../docs/reference/package-repository-contract.md). +It is not a complete repository policy. Add the adopter's CI, review, rulesets, +and ownership controls. -## Files to copy +## Copy the example -Copy these release files into an existing Go repository, preserving their paths: +Copy these paths into an existing Go repository without overwriting its source +or module files: - `.github/workflows/release-please.yml` - `.github/workflows/release.yml` @@ -18,68 +23,69 @@ Copy these release files into an existing Go repository, preserving their paths: - `mise.toml` - `mise.lock` -To reproduce the complete minimal consumer in a new empty repository, also copy: - -- `go.mod` -- `cmd/example/main.go` - -Do not copy this README into the consumer repository. - -## Release build - -The producer workflow sets up `release-cli` and runs: - -```text -release-cli stage --profile go --dist dist +For a new disposable repository, also copy `go.mod` and `cmd/example/`. Do not +copy this README into the producer. + +## Replace the template values + +Before the workflows run: + +1. Replace every `REPLACE_WITH_RELEASE_COMMIT_SHA` in + `.github/workflows/release.yml` with one reviewed, full 40-character + `meigma/release` commit SHA. +2. Replace `OWNER/REPOSITORY` in `.goreleaser.yaml` and `apko.yaml` with the + producer owner and repository. +3. Replace `HOMEBREW-OWNER`, `HOMEBREW-TAP`, `SCOOP-OWNER`, + `SCOOP-BUCKET`, and the `PACKAGE-REPOSITORY-*` values only after those + adopter-owned destinations exist. +4. Replace the `example` project, package, binary, cask, manifest, command path, + module path, and Release Please package name. +5. Replace the organization metadata, maintainer, description, homepage, and + SPDX license expression. +6. Change the Release Please branch and manifest version when the repository + does not use a new `main`-branch release history. +7. Update the linker variables when the command does not define + `main.version` and `main.commit`. + +The one full SHA selects every reusable workflow and checksum signer identity. +It also selects the sibling setup action and the CLI release stamp. Do not mix +release-unit revisions or add an independent CLI pin. + +## Default safety controls + +The caller begins with: + +```yaml +sign-and-notarize-macos: false +sign-native-packages: false +publish-image: false +publish-release: false +publish-homebrew: false +publish-scoop: false +publish-package-repository: false ``` -The stage command runs `goreleaser release --clean --skip=publish` under mise's environment, then validates the release bundle and writes the OCI input projection. GoReleaser shells out to the mise-managed Go, Syft, and Cosign executables during the build. - -The GoReleaser configuration packages each Linux build as DEB, RPM, and APK -and emits one SBOM for every archive and native package. These standalone -packages are GitHub Release assets. The example does not configure native -RPM or APK signing. Its package-repository request remains disabled until the -producer is reviewed, allowlisted, and configured with both signing keys. - -GoReleaser has no command-line distribution-directory option in this invocation. The consumer's `.goreleaser.yaml` must write the same distribution directory that the workflow passes to `release-cli stage --dist`. This example uses GoReleaser's default `dist` directory and passes `--dist dist`. - -The `--clean` option deletes and rebuilds `dist`. Keep `release.disable: true` in `.goreleaser.yaml`; `--skip=publish` is a second boundary against GoReleaser publication. Keep `changelog.disable: true` because Release Please owns release notes. - -## Values to replace - -Replace these project-specific example values: - -- `example.com/meigma/release-consumer` in `go.mod` with the consumer's module path. -- `./cmd/example` in `.goreleaser.yaml` with the consumer command package. -- Project name, build ID, archive ID, nFPM ID, binary name, and Release Please package name `example` with the consumer's project and binary names. -- Package name, vendor, homepage, maintainer, description, license, and installed command path in `.goreleaser.yaml` and `melange.yaml`. -- Package name, entrypoint, image annotations, and source URL in `apko.yaml`. -- The literal command name and default output in `cmd/example/main.go` if you copy the sample command. -- Branch name `main` in `.github/workflows/release-please.yml` if the consumer uses another default branch. -- `initial-version` value `0.1.0` in `release-please-config.json` if the first intended release differs. -- Manifest value `0.0.0` in `.release-please-manifest.json` if the consumer already has a release. Use its latest released version without the `v` prefix. -- Linker variables `main.version` and `main.commit` in `.goreleaser.yaml` if the consumer command exposes version data through different variables. The copied sample defines both variables and prints `example ()` for `--version`. -- Package repository owner and repository name in `.github/workflows/release.yml` with the reviewed central receiver. - -The release-asset and OCI jobs use one full commit SHA for their reusable -workflow references and checksum signing identity. That SHA is their consumer -pin for the complete release unit. The current baseline pin is the `v0.1.3` -release revision, `0fc99489d31d400bc3f69d6636d60e7d3f3d0251`. - -The disabled package-repository job uses the `v0.1.16` revision, -`583937edadfbae183e49f16df46b98e0b36807ba`, because that revision introduced -the dispatch workflow and multi-version repository fix. Upgrade the complete -release unit to a reviewed current revision before enabling the job. - -Keep these contract values unchanged: - -- all four reusable workflow references at `0fc99489d31d400bc3f69d6636d60e7d3f3d0251`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251`; -- disabled package-repository workflow reference at `583937edadfbae183e49f16df46b98e0b36807ba`; -- organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`; -- organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY`; and -- the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, GitHub CLI 2.97.0, Melange 0.59.1, and apko 1.2.37 versions unless the shared workflow contract is deliberately updated. - -The caller sets `publish-image: false`, `publish-release: false`, and `publish-package-repository: false`. The first run therefore leaves a populated draft without writing to GHCR or dispatching to a central package repository. After inspecting the draft and `oci-image` artifact, change the image and GitHub Release inputs to `true` and follow the recovery guide to publish through the same tag and release. Enable package-repository dispatch only after the release is public, the central receiver allowlists the producer, native RPM and APK signing is configured, and the receiver's protected environment is ready. - -The OCI builder retains the signed APK repository, apko lock, SPDX files, and OCI layout in the `oci-image` workflow artifact. The separate publisher verifies that artifact before pushing, signing, and attesting `ghcr.io/owner/repository`. +The first tag therefore builds and verifies release assets and the OCI layout, +populates a draft GitHub Release, and performs a dry-run registry plan. It does +not write GHCR, open a tap or bucket pull request, dispatch native package +publication, or make the draft public. + +The supported publication configuration enables `publish-image` and +`publish-release` together. Enable Homebrew, Scoop, or package-repository +publication only in a run that also makes the GitHub Release public. + +The Homebrew and Scoop entries use `skip_upload: true`. GoReleaser generates +their controls inside the authoritative Actions artifact, but the dedicated +publishers own destination pull requests. The package-repository request stays +disabled until producer-native RPM and APK signing, central policy, public +keys, R2, and the protected receiver environment exist. + +## Documentation + +- [Release your first Go application](../../docs/tutorials/release-your-first-go-application.md) +- [Prepare your GitHub organization](../../docs/how-to/prepare-your-github-organization.md) +- [Adopt the release workflows](../../docs/how-to/adopt-the-release-workflows.md) +- [Add Homebrew and Scoop](../../docs/how-to/add-homebrew-and-scoop.md) +- [Operate a native package repository](../../docs/how-to/operate-a-native-package-repository.md) +- [Operate and recover releases](../../docs/how-to/operate-and-recover-releases.md) +- [Release system reference](../../docs/reference/release-system.md) diff --git a/examples/go-release/apko.yaml b/examples/go-release/apko.yaml index aec61cc..30a1539 100644 --- a/examples/go-release/apko.yaml +++ b/examples/go-release/apko.yaml @@ -28,6 +28,6 @@ archs: annotations: org.opencontainers.image.title: example - org.opencontainers.image.description: Example Go command using the Meigma release workflows. + org.opencontainers.image.description: Example Go command using the reusable release workflows. org.opencontainers.image.source: https://github.com/OWNER/REPOSITORY - org.opencontainers.image.licenses: LicenseRef-Proprietary + org.opencontainers.image.licenses: Apache-2.0 OR MIT diff --git a/examples/go-release/cmd/example/main.go b/examples/go-release/cmd/example/main.go index 172f7d8..197de19 100644 --- a/examples/go-release/cmd/example/main.go +++ b/examples/go-release/cmd/example/main.go @@ -26,6 +26,6 @@ func run() int { return 0 } - fmt.Println("example is a copyable Meigma GitHub Release consumer.") + fmt.Println("example is a copyable reusable-workflow consumer.") return 0 } diff --git a/examples/go-release/go.mod b/examples/go-release/go.mod index 0d83667..32f0c63 100644 --- a/examples/go-release/go.mod +++ b/examples/go-release/go.mod @@ -1,3 +1,3 @@ -module example.com/meigma/release-consumer +module example.com/acme/widget go 1.26.6 diff --git a/examples/go-release/melange.yaml b/examples/go-release/melange.yaml index b1ccce9..ac7fe2e 100644 --- a/examples/go-release/melange.yaml +++ b/examples/go-release/melange.yaml @@ -2,15 +2,15 @@ package: name: example version: ${{vars.version}} epoch: 0 - description: Example Go command using the Meigma release workflows. + description: Example Go command using the reusable release workflows. target-architecture: - x86_64 - aarch64 - vendor: Meigma - homepage: https://github.com/meigma/release - maintainer: Meigma + vendor: Example Organization + homepage: https://github.com/OWNER/REPOSITORY + maintainer: Release Team copyright: - - license: LicenseRef-Proprietary + - license: Apache-2.0 OR MIT vars: version: 0.0.0 diff --git a/examples/nix-release-cli/flake.lock b/examples/nix-release-cli/flake.lock index 5e01d85..14cc6e5 100644 --- a/examples/nix-release-cli/flake.lock +++ b/examples/nix-release-cli/flake.lock @@ -23,16 +23,16 @@ ] }, "locked": { - "lastModified": 1787262491, - "narHash": "sha256-TcIiBeZx+Qnirg0B1WYLWTRzx4+5ypgi7FKn3ur//Fw=", + "lastModified": 1787371867, + "narHash": "sha256-E4TvKtirTwWJUMFCT9CJImsS/TMkuy0acciRgkTDQw4=", "owner": "meigma", "repo": "release", - "rev": "0fc99489d31d400bc3f69d6636d60e7d3f3d0251", + "rev": "583937edadfbae183e49f16df46b98e0b36807ba", "type": "github" }, "original": { "owner": "meigma", - "ref": "v0.1.3", + "ref": "v0.1.16", "repo": "release", "type": "github" } diff --git a/examples/nix-release-cli/flake.nix b/examples/nix-release-cli/flake.nix index 047326b..4429acd 100644 --- a/examples/nix-release-cli/flake.nix +++ b/examples/nix-release-cli/flake.nix @@ -4,7 +4,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; release = { - url = "github:meigma/release/v0.1.3"; + url = "github:meigma/release/v0.1.16"; inputs.nixpkgs.follows = "nixpkgs"; }; }; diff --git a/flake.nix b/flake.nix index db819ad..838ac1b 100644 --- a/flake.nix +++ b/flake.nix @@ -50,6 +50,7 @@ meta = { description = "Validate and publish Meigma release artifacts"; homepage = "https://github.com/meigma/release"; + license = with pkgs.lib.licenses; [ asl20 mit ]; mainProgram = "release-cli"; platforms = systems; }; diff --git a/internal/adapter/ghattest/verifier.go b/internal/adapter/ghattest/verifier.go index 76174f1..61a93d9 100644 --- a/internal/adapter/ghattest/verifier.go +++ b/internal/adapter/ghattest/verifier.go @@ -106,10 +106,8 @@ func (v *Verifier) Verify(ctx context.Context, request pkgrepo.AttestationReques if !commitPattern.MatchString(request.SourceDigest) { return fmt.Errorf("source digest %q is not a full lowercase SHA", request.SourceDigest) } - workflowPrefix := string(request.Repository) + "/.github/workflows/" - if !strings.HasPrefix(request.SignerWorkflow, workflowPrefix) || - strings.Contains(strings.TrimPrefix(request.SignerWorkflow, workflowPrefix), "/") { - return fmt.Errorf("signer workflow %q does not belong to %q", request.SignerWorkflow, request.Repository) + if _, parseErr := pkgrepo.ParseAttestationSigner(string(request.SignerWorkflow)); parseErr != nil { + return parseErr } err = execx.Run(ctx, execx.Command{ @@ -118,7 +116,7 @@ func (v *Verifier) Verify(ctx context.Context, request pkgrepo.AttestationReques Args: []string{ "attestation", "verify", request.Path, "--repo", string(request.Repository), - "--signer-workflow", request.SignerWorkflow, + "--signer-workflow", string(request.SignerWorkflow), "--source-ref", request.SourceRef, "--source-digest", request.SourceDigest, "--deny-self-hosted-runners", diff --git a/internal/adapter/ghattest/verifier_test.go b/internal/adapter/ghattest/verifier_test.go index d28bea9..a71063e 100644 --- a/internal/adapter/ghattest/verifier_test.go +++ b/internal/adapter/ghattest/verifier_test.go @@ -38,20 +38,20 @@ func TestVerifyBindsArtifactToExactGitHubProvenance(t *testing.T) { err := fixture.verifier.Verify(context.Background(), fixture.request) require.NoError(t, err) - assert.Equal(t, []string{ - "attestation", - "verify", - fixture.request.Path, - "--repo", - "meigma/release", - "--signer-workflow", - "meigma/release/.github/workflows/publish-github-release.yml", - "--source-ref", - "refs/tags/v1.2.3", - "--source-digest", - "0123456789abcdef0123456789abcdef01234567", - "--deny-self-hosted-runners", - }, recordedArguments(t, fixture.record)) + assert.Equal(t, fixture.expectedArguments(), recordedArguments(t, fixture.record)) +} + +func TestVerifyAcceptsCrossRepositorySigner(t *testing.T) { + skipWindows(t) + t.Parallel() + + fixture := newVerifierFixture(t) + fixture.request.Repository = "acme/app" + fixture.request.SignerWorkflow = "meigma/release/.github/workflows/publish-github-release.yml" + + err := fixture.verifier.Verify(context.Background(), fixture.request) + require.NoError(t, err) + assert.Equal(t, fixture.expectedArguments(), recordedArguments(t, fixture.record)) } func TestVerifyRedactsTokenFromFailure(t *testing.T) { @@ -95,9 +95,9 @@ func TestVerifyRejectsUnboundRequestsBeforeExecution(t *testing.T) { mutate: func(request *pkgrepo.AttestationRequest) { request.SourceDigest = "deadbeef" }, wantErr: "full lowercase SHA", }, - {name: "other signer repository", mutate: func(request *pkgrepo.AttestationRequest) { - request.SignerWorkflow = "other/repo/.github/workflows/publish.yml" - }, wantErr: "does not belong"}, + {name: "malformed signer workflow", mutate: func(request *pkgrepo.AttestationRequest) { + request.SignerWorkflow = ".github/workflows/publish.yml" + }, wantErr: "owner/repository/.github/workflows/"}, } for _, test := range tests { @@ -153,6 +153,24 @@ func newVerifierFixture(t *testing.T) verifierFixture { } } +// expectedArguments returns the exact gh argv for the fixture request. +func (f verifierFixture) expectedArguments() []string { + return []string{ + "attestation", + "verify", + f.request.Path, + "--repo", + string(f.request.Repository), + "--signer-workflow", + string(f.request.SignerWorkflow), + "--source-ref", + f.request.SourceRef, + "--source-digest", + f.request.SourceDigest, + "--deny-self-hosted-runners", + } +} + // recordedArguments reads one NUL-delimited fake process record. func recordedArguments(t *testing.T, name string) []string { t.Helper() diff --git a/internal/cli/package_repository_test.go b/internal/cli/package_repository_test.go index 5e1a6b2..db2a0a1 100644 --- a/internal/cli/package_repository_test.go +++ b/internal/cli/package_repository_test.go @@ -33,8 +33,8 @@ producers: - repository: meigma/release packages: - release-cli - checksum_workflow: .github/workflows/go-pre-publish.yml - attestation_workflow: .github/workflows/publish-github-release.yml + checksum_identity: https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567 + attestation_signer: meigma/release/.github/workflows/publish-github-release.yml rpm_key: source: keys/release-rpm.asc published: release-rpm-001.asc diff --git a/internal/stage/pkgrepo/config.go b/internal/stage/pkgrepo/config.go index fa5794d..6192535 100644 --- a/internal/stage/pkgrepo/config.go +++ b/internal/stage/pkgrepo/config.go @@ -52,10 +52,10 @@ type producerFile struct { Repository string `yaml:"repository"` // Packages is the complete producer-owned package allowlist. Packages []string `yaml:"packages"` - // ChecksumWorkflow signs checksums.txt and its Sigstore bundle. - ChecksumWorkflow string `yaml:"checksum_workflow"` - // AttestationWorkflow creates GitHub build-provenance attestations. - AttestationWorkflow string `yaml:"attestation_workflow"` + // ChecksumIdentity is the exact Cosign certificate identity that signs checksums.txt. + ChecksumIdentity string `yaml:"checksum_identity"` + // AttestationSigner is the GitHub workflow that attests release payloads. + AttestationSigner string `yaml:"attestation_signer"` // RPMKey verifies producer RPM package signatures. RPMKey publicKeyFile `yaml:"rpm_key"` // APKKey verifies producer APK package signatures. @@ -130,9 +130,9 @@ func mapPublicationConfig(file publicationConfigFile) (PublicationConfig, error) APKKey: mapPublicKey(source.APKKey), }) config.Sources = append(config.Sources, SourcePolicy{ - Repository: repository, - ChecksumWorkflow: source.ChecksumWorkflow, - AttestationWorkflow: source.AttestationWorkflow, + Repository: repository, + ChecksumIdentity: ChecksumIdentity(source.ChecksumIdentity), + AttestationSigner: AttestationSigner(source.AttestationSigner), }) } diff --git a/internal/stage/pkgrepo/config_test.go b/internal/stage/pkgrepo/config_test.go index 4578f1b..9160a27 100644 --- a/internal/stage/pkgrepo/config_test.go +++ b/internal/stage/pkgrepo/config_test.go @@ -8,6 +8,11 @@ import ( "github.com/stretchr/testify/require" ) +const ( + validChecksumIdentity = "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567" + validAttestationSigner = "meigma/release/.github/workflows/publish-github-release.yml" +) + func TestParsePublicationConfig(t *testing.T) { t.Parallel() @@ -17,6 +22,15 @@ func TestParsePublicationConfig(t *testing.T) { wantErr string }{ {name: "accepted reviewed policy", input: validPublicationConfig()}, + { + name: "accepted cross-repository signer", + input: strings.Replace( + validPublicationConfig(), + "repository: meigma/release", + "repository: acme/app", + 1, + ), + }, { name: "unknown field", input: strings.Replace(validPublicationConfig(), "origin:", "unknown: value\norigin:", 1), @@ -43,14 +57,14 @@ func TestParsePublicationConfig(t *testing.T) { wantErr: "path prefix", }, { - name: "invalid checksum workflow", + name: "invalid checksum identity", input: strings.Replace( validPublicationConfig(), - ".github/workflows/go-pre-publish.yml", - "scripts/publish.yml", + validChecksumIdentity, + "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@refs/tags/v1.2.3", 1, ), - wantErr: "checksum workflow", + wantErr: "checksum identity", }, { name: "invalid repository", @@ -79,35 +93,144 @@ func TestParsePublicationConfig(t *testing.T) { assert.Equal(t, ChannelStable, got.Repository.Channel) assert.Equal(t, "https://pkgs.meigma.dev", got.Origin) require.Len(t, got.Repository.Producers, 1) + require.Len(t, got.Sources, 1) + assert.Equal(t, ChecksumIdentity(validChecksumIdentity), got.Sources[0].ChecksumIdentity) + assert.Equal(t, AttestationSigner(validAttestationSigner), got.Sources[0].AttestationSigner) + if strings.Contains(test.name, "cross-repository") { + assert.Equal(t, Repository("acme/app"), got.Repository.Producers[0].Repository) + assert.Equal(t, Repository("acme/app"), got.Sources[0].Repository) + return + } assert.Equal(t, Repository("meigma/release"), got.Repository.Producers[0].Repository) assert.Equal(t, []PackageName{"release-cli"}, got.Repository.Producers[0].Packages) - require.Len(t, got.Sources, 1) - assert.Equal(t, ".github/workflows/go-pre-publish.yml", got.Sources[0].ChecksumWorkflow) }) } } -func TestSourcePolicyDerivesExactIdentities(t *testing.T) { +func TestParseChecksumIdentityRejectsNonImmutableValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "tag ref", + input: "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@refs/tags/v1.2.3", + wantErr: "full lowercase commit SHA", + }, + { + name: "branch ref", + input: "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@main", + wantErr: "full lowercase commit SHA", + }, + { + name: "missing ref", + input: "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml", + wantErr: "immutable commit SHA", + }, + { + name: "short SHA", + input: "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@0123456789abcdef01234567", + wantErr: "full lowercase commit SHA", + }, + { + name: "relative identity", + input: ".github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567", + wantErr: "absolute HTTPS URL", + }, + { + name: "non-GitHub host", + input: "https://gitlab.com/meigma/release/.github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567", + wantErr: "host github.com", + }, + { + name: "credentials", + input: "https://user:token@github.com/meigma/release/.github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567", + wantErr: "credentials", + }, + { + name: "query", + input: validChecksumIdentity + "?ref=main", + wantErr: "query", + }, + { + name: "fragment", + input: validChecksumIdentity + "#section", + wantErr: "fragment", + }, + { + name: "uppercase owner", + input: "https://github.com/Meigma/release/.github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567", + wantErr: "lowercase owner/name", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := ParseChecksumIdentity(test.input) + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestParseAttestationSignerRejectsMalformedValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "relative workflow", + input: ".github/workflows/publish-github-release.yml", + wantErr: "owner/repository/.github/workflows/", + }, + { + name: "URL identity", + input: "https://github.com/meigma/release/.github/workflows/publish-github-release.yml", + wantErr: "owner/repository/.github/workflows/", + }, + { + name: "pinned ref", + input: "meigma/release/.github/workflows/publish-github-release.yml@0123456789abcdef0123456789abcdef01234567", + wantErr: "owner/repository/.github/workflows/", + }, + { + name: "uppercase owner", + input: "Meigma/release/.github/workflows/publish-github-release.yml", + wantErr: "lowercase owner/name", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := ParseAttestationSigner(test.input) + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestSourcePolicyKeepsExplicitIdentities(t *testing.T) { t.Parallel() policy := SourcePolicy{ - Repository: "meigma/release", - ChecksumWorkflow: ".github/workflows/go-pre-publish.yml", - AttestationWorkflow: ".github/workflows/publish-github-release.yml", + Repository: "acme/app", + ChecksumIdentity: ChecksumIdentity(validChecksumIdentity), + AttestationSigner: AttestationSigner(validAttestationSigner), } - identity, err := policy.ChecksumIdentity("v1.2.3") - require.NoError(t, err) - assert.Equal( - t, - "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@refs/tags/v1.2.3", - identity, - ) - assert.Equal( - t, - "meigma/release/.github/workflows/publish-github-release.yml", - policy.AttestationSigner(), - ) + require.NoError(t, policy.Validate()) + assert.Equal(t, ChecksumIdentity(validChecksumIdentity), policy.ChecksumIdentity) + assert.Equal(t, AttestationSigner(validAttestationSigner), policy.AttestationSigner) } func TestPackageObjectFormatAcceptsOnlyCanonicalTrees(t *testing.T) { @@ -172,8 +295,8 @@ func validProducerYAML() string { return ` - repository: meigma/release packages: - release-cli - checksum_workflow: .github/workflows/go-pre-publish.yml - attestation_workflow: .github/workflows/publish-github-release.yml + checksum_identity: ` + validChecksumIdentity + ` + attestation_signer: ` + validAttestationSigner + ` rpm_key: source: keys/release-rpm.asc published: release-rpm-001.asc diff --git a/internal/stage/pkgrepo/publish.go b/internal/stage/pkgrepo/publish.go index 3d394fe..6ffcae3 100644 --- a/internal/stage/pkgrepo/publish.go +++ b/internal/stage/pkgrepo/publish.go @@ -118,11 +118,12 @@ func (p *Publisher) Publish(ctx context.Context, input PublishInput) (PublishRes return PublishResult{}, err } - identity, err := policy.ChecksumIdentity(input.Request.Tag) - if err != nil { - return PublishResult{}, err + if policyErr := policy.Validate(); policyErr != nil { + return PublishResult{}, policyErr } - bundle, err := pubgh.VerifyBundle(ctx, releaseRoot.FS(), p.bundles, pubgh.TrustPolicy{Identity: identity}) + bundle, err := pubgh.VerifyBundle(ctx, releaseRoot.FS(), p.bundles, pubgh.TrustPolicy{ + Identity: string(policy.ChecksumIdentity), + }) if err != nil { return PublishResult{}, fmt.Errorf("verify release bundle: %w", err) } @@ -336,7 +337,7 @@ func (p *Publisher) verifyReleasePackages( Repository: input.Request.Repository, SourceRef: "refs/tags/" + input.Request.Tag, SourceDigest: release.Commit, - SignerWorkflow: policy.AttestationSigner(), + SignerWorkflow: policy.AttestationSigner, }); err != nil { return nil, nil, fmt.Errorf("verify package attestation %s: %w", entry.Name, err) } diff --git a/internal/stage/pkgrepo/publish_test.go b/internal/stage/pkgrepo/publish_test.go index 7e5abb1..3cb37e9 100644 --- a/internal/stage/pkgrepo/publish_test.go +++ b/internal/stage/pkgrepo/publish_test.go @@ -26,6 +26,7 @@ import ( "github.com/meigma/release/internal/rel" "github.com/meigma/release/internal/stage" "github.com/meigma/release/internal/stage/pkgrepo" + "github.com/meigma/release/internal/stage/pubgh" ) // TestPublisherPublishesVerifiedRelease proves the complete package-repository orchestration contract. @@ -47,8 +48,13 @@ func TestPublisherPublishesVerifiedRelease(t *testing.T) { Repository: "meigma/release", Tag: "v1.2.3", }, mock.Anything).RunAndReturn(fixture.fetchRelease).Once() - bundles.EXPECT().Verify(mock.Anything, mock.Anything).Return(nil).Once() - attestations.EXPECT().Verify(mock.Anything, mock.Anything).Return(nil).Times(len(fixture.assets)) + bundles.EXPECT().Verify(mock.Anything, mock.MatchedBy(func(request pubgh.BlobVerification) bool { + return request.Identity == "https://github.com/shared/workflows/.github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567" + })).Return(nil).Once() + attestations.EXPECT().Verify(mock.Anything, mock.MatchedBy(func(request pkgrepo.AttestationRequest) bool { + return request.Repository == "meigma/release" && + request.SignerWorkflow == "shared/workflows/.github/workflows/publish-github-release.yml" + })).Return(nil).Times(len(fixture.assets)) store.EXPECT().List(mock.Anything).Return(nil, nil).Once() for index, asset := range fixture.assets { inspector.EXPECT().Inspect( @@ -185,11 +191,13 @@ func (f *publisherFixture) input() pkgrepo.PublishInput { Config: pkgrepo.PublicationConfig{ Origin: "https://pkgs.meigma.dev", Repository: buildConfig(), - Sources: []pkgrepo.SourcePolicy{{ - Repository: "meigma/release", - ChecksumWorkflow: ".github/workflows/go-pre-publish.yml", - AttestationWorkflow: ".github/workflows/publish-github-release.yml", - }}, + Sources: []pkgrepo.SourcePolicy{ + { + Repository: "meigma/release", + ChecksumIdentity: "https://github.com/shared/workflows/.github/workflows/go-pre-publish.yml@0123456789abcdef0123456789abcdef01234567", + AttestationSigner: "shared/workflows/.github/workflows/publish-github-release.yml", + }, + }, }, Request: pkgrepo.Request{Repository: "meigma/release", Tag: "v1.2.3"}, Keys: f.keys, diff --git a/internal/stage/pkgrepo/publish_values.go b/internal/stage/pkgrepo/publish_values.go index f60e995..5eb5492 100644 --- a/internal/stage/pkgrepo/publish_values.go +++ b/internal/stage/pkgrepo/publish_values.go @@ -31,14 +31,20 @@ var ( // PublishState is the converged remote repository state. type PublishState string -// SourcePolicy binds one producer to its trusted checksum and attestation workflows. +// ChecksumIdentity is one exact Cosign certificate identity URL. +type ChecksumIdentity string + +// AttestationSigner is one GitHub attestation signer workflow. +type AttestationSigner string + +// SourcePolicy binds one producer to explicit shared-workflow trust identities. type SourcePolicy struct { // Repository is the allowlisted producer repository. Repository Repository - // ChecksumWorkflow is the repository-relative workflow that signs checksums.txt. - ChecksumWorkflow string - // AttestationWorkflow is the repository-relative workflow that attests release payloads. - AttestationWorkflow string + // ChecksumIdentity is the exact Cosign certificate identity that signs checksums.txt. + ChecksumIdentity ChecksumIdentity + // AttestationSigner is the GitHub workflow that attests release payloads. + AttestationSigner AttestationSigner } // PublicationConfig is the complete reviewed package-repository publication policy. @@ -95,8 +101,8 @@ type AttestationRequest struct { SourceRef string // SourceDigest is the full source commit SHA. SourceDigest string - // SignerWorkflow is the repository-relative trusted workflow path. - SignerWorkflow string + // SignerWorkflow is the trusted GitHub attestation signer workflow. + SignerWorkflow AttestationSigner } // StoredObject is one object discovered in repository storage. @@ -244,31 +250,104 @@ func (p SourcePolicy) Validate() error { if _, err := ParseRepository(string(p.Repository)); err != nil { return err } - if !workflowPattern.MatchString(p.ChecksumWorkflow) { - return fmt.Errorf("checksum workflow %q is invalid", p.ChecksumWorkflow) + if _, err := ParseChecksumIdentity(string(p.ChecksumIdentity)); err != nil { + return err } - if !workflowPattern.MatchString(p.AttestationWorkflow) { - return fmt.Errorf("attestation workflow %q is invalid", p.AttestationWorkflow) + if _, err := ParseAttestationSigner(string(p.AttestationSigner)); err != nil { + return err } return nil } -// ChecksumIdentity returns the exact GitHub Actions certificate identity for tag. -func (p SourcePolicy) ChecksumIdentity(tag string) (string, error) { - if err := p.Validate(); err != nil { - return "", err +// ParseChecksumIdentity validates one exact Cosign certificate identity. +// +// The identity must be +// https://github.com///.github/workflows/@<40-character-sha> +// with a lowercase owner and repository. Branches, tags, missing refs, +// credentials, queries, fragments, and non-GitHub hosts are rejected. +func ParseChecksumIdentity(value string) (ChecksumIdentity, error) { + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("checksum identity %q is invalid: %w", value, err) + } + if parsed.Scheme != "https" || parsed.Host == "" { + return "", fmt.Errorf("checksum identity %q must be an absolute HTTPS URL", value) } - if _, err := ParseTag(tag); err != nil { - return "", err + if parsed.Host != "github.com" { + return "", fmt.Errorf("checksum identity %q must use host github.com", value) + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("checksum identity %q must not contain credentials, a query, or a fragment", value) } - return "https://github.com/" + string(p.Repository) + "/" + p.ChecksumWorkflow + "@refs/tags/" + tag, nil + pathAndRef := strings.TrimPrefix(parsed.EscapedPath(), "/") + workflowPath, ref, found := strings.Cut(pathAndRef, "@") + if !found || ref == "" { + return "", fmt.Errorf("checksum identity %q is missing an immutable commit SHA", value) + } + if !commitPattern.MatchString(ref) { + return "", fmt.Errorf("checksum identity %q must pin a full lowercase commit SHA", value) + } + repository, workflow, ok := splitOwnerWorkflow(workflowPath) + if !ok { + return "", fmt.Errorf("checksum identity %q is not a GitHub workflow identity", value) + } + if _, parseErr := ParseRepository(repository); parseErr != nil { + return "", fmt.Errorf("checksum identity %q: %w", value, parseErr) + } + if !workflowPattern.MatchString(workflow) { + return "", fmt.Errorf("checksum identity %q has an invalid workflow path", value) + } + canonical := "https://github.com/" + repository + "/" + workflow + "@" + ref + if value != canonical { + return "", fmt.Errorf("checksum identity %q is invalid", value) + } + + return ChecksumIdentity(value), nil +} + +// ParseAttestationSigner validates one GitHub attestation signer workflow. +// +// The signer must be //.github/workflows/ with a lowercase +// owner and repository. URLs, refs, credentials, and relative paths are rejected. +func ParseAttestationSigner(value string) (AttestationSigner, error) { + if strings.Contains(value, "://") || strings.ContainsAny(value, "?#@") { + return "", fmt.Errorf( + "attestation signer %q must be owner/repository/.github/workflows/", + value, + ) + } + repository, workflow, ok := splitOwnerWorkflow(value) + if !ok { + return "", fmt.Errorf( + "attestation signer %q must be owner/repository/.github/workflows/", + value, + ) + } + if _, err := ParseRepository(repository); err != nil { + return "", fmt.Errorf("attestation signer %q: %w", value, err) + } + if !workflowPattern.MatchString(workflow) { + return "", fmt.Errorf("attestation signer %q has an invalid workflow path", value) + } + canonical := repository + "/" + workflow + if value != canonical { + return "", fmt.Errorf("attestation signer %q is invalid", value) + } + + return AttestationSigner(value), nil } -// AttestationSigner returns the gh signer-workflow value for this producer. -func (p SourcePolicy) AttestationSigner() string { - return string(p.Repository) + "/" + p.AttestationWorkflow +// splitOwnerWorkflow splits owner/name/.github/workflows/ into repository and workflow path. +func splitOwnerWorkflow(value string) (string, string, bool) { + const marker = "/.github/workflows/" + repository, file, found := strings.Cut(value, marker) + if !found || repository == "" || file == "" || strings.Contains(file, "/") { + return "", "", false + } + + return repository, ".github/workflows/" + file, true } // Validate checks one downloaded release and its exact source binding. diff --git a/melange.yaml b/melange.yaml index e6f79a3..244e3d7 100644 --- a/melange.yaml +++ b/melange.yaml @@ -7,7 +7,7 @@ package: - x86_64 - aarch64 copyright: - - license: LicenseRef-Proprietary + - license: Apache-2.0 OR MIT vars: version: 0.0.0