The repo publishes three artifacts, each on its own version and released by its own tag:
| Artifact | Tag | Workflow | Published to | Install |
|---|---|---|---|---|
decdn.node Ansible collection (node operators) |
node-collection-vX.Y.Z |
release-collection.yml |
Ansible Galaxy | ansible-galaxy collection install decdn.node:==X.Y.Z |
decdn.publisher Ansible collection (publishers) |
publisher-collection-vX.Y.Z |
release-collection.yml |
Ansible Galaxy | ansible-galaxy collection install decdn.publisher:==X.Y.Z |
decdn-node Helm chart |
decdn-node-X.Y.Z |
release-chart.yml |
oci://ghcr.io/decdn/charts, signed with cosign (keyless), with build-provenance attestations after 0.1.0 |
helm install <release> oci://ghcr.io/decdn/charts/decdn-node --version X.Y.Z |
Release an artifact when it has changes; the others are not touched. Their versions are
independent. The one dependency is Galaxy's: decdn.publisher requires decdn.node
(its playbooks run decdn.node's baseline, Alloy and origin nodes), so a publisher
release that raises its decdn.node floor comes after that node release. scripts/release.sh
refuses any publisher release whose decdn.node: ">=X.Y.Z" no node-collection-v*
tag on origin satisfies yet. A tag is not a Galaxy upload, so the publish job also
asks Galaxy, before uploading a decdn.publisher, for a decdn.node that satisfies
it, and fails while there is none: publish that node release, then re-run the job. What the artifacts must agree on is checked by CI, not by a shared
version: the decdn release line (the chart's appVersion and the role's
decdn_node_version) on every PR (make test-scripts), and the config keys each one
renders against the same upstream schema-key inventory whenever either changes
(make lint-helm, molecule/schema). When a change lands in several (a decdn bump
touches the node collection, the publisher collection and the chart), release each.
Each workflow's build job runs on every matching tag push. It gates the tag
(scripts/check-release-version.sh), re-runs that artifact's checks (make galaxy-check-node, make galaxy-check-publisher or make lint-helm), packages it,
and uploads it with a SHA256SUMS as a workflow artifact. The publish job pushes it to Galaxy or ghcr.io and creates the GitHub
Release, but only when the repository variable PUBLISH_ENABLED is true, as it
is now; set it to anything else and a tag push is a dry run. So is a manual Run workflow (workflow_dispatch) with an
existing tag. Before a tag exists, the rehearsal is scripts/release.sh's dry run and the
artifact's make target (below). The repo's Latest release is the highest decdn.node
version, the node operator's collection: a node collection release is marked Latest only
when no higher node-collection-v release exists (so a patch to an older line does not
take it), and publisher collection and chart releases never are (--latest=false).
Collection publishes run one at a time, so two cannot race for it.
A release is cut on main, with no release PR, by
scripts/release.sh, a wrapper around
git-cliff in the manner of cargo release:
scripts/release.sh chart # dry run: the new tag and the diff
scripts/release.sh chart --execute # bump, check, commit, tag, push
scripts/release.sh node-collection minor --executeThe first argument is the artifact (node-collection, publisher-collection or
chart); the second is the level:
auto (the default), patch, minor, major or an explicit X.Y.Z. Without
--execute, it is a dry run: it runs steps 1 to 4, prints the new tag and the diff it
would commit, and changes no file. The script:
- Refuses unconventional commits. It looks at the commits since the artifact's
last tag that touched what it ships: for a collection, the roles its
ansible/galaxy/<collection>/roles.txtlists and the playbooks itsplaybooks.txtlists (the listsgalaxy/build.shandgalaxy/stage-playbooks.pyread) withansible/playbooks/group_vars/, that overlay,ansible/galaxy/build.sh,ansible/galaxy/stage-playbooks.pyandLICENSE; for the chart,charts/decdn-node/andmonitoring/decdn-node/. git-cliff drops a commit it cannot parse as a Conventional Commit, from the changelog and from the bump, and only logs a count. That covers an unconventional subject, but also an empty scope (feat(): …) and a body right under the subject with no blank line. The script has git-cliff name every such commit (require_conventional) and stops;--allow-unconventionalreleases without them. - Picks the version.
autois the bump git-cliff derives (cliff.toml) from those commits. Before 1.0, a feature bumps the minor, and so does a breaking change from 0.1 on (at 0.0.x it bumps the patch); any other commit kept in the changelog bumps the patch. It refuses when none of those commits makes a changelog entry (they are allci,test,style,buildor release commits, or unconventional ones--allow-unconventionalleaves out), whatever the level. - Sets
version:inansible/galaxy/<collection>/galaxy.ymlorcharts/decdn-node/Chart.yaml. The chart'sappVersionand itsartifacthub.io/imagestag already follow the role'sdecdn_node_version(make test-scriptsrefuses a PR where they differ), so a new decdn line is a chart change too: release the chart. A decdn patch release in the pinned line needs no release of anything (the roles and the chart's default tag follow the line); its bump PR only moves Compose and the examples. - Writes the changelog section. It renders the same commits into a dated
## [X.Y.Z] — YYYY-MM-DDsection at the top of the artifact'sCHANGELOG.md:featunder Added,fixunder Fixed,revertunder Removed,securityunder Security, andperf,refactor,docs,choreand any other type under Changed (types match case-insensitively).ci,test,styleandbuildcommits are left out unless breaking (those go under Changed); release commits always are. A commit is breaking with a!or aBREAKING CHANGE:footer, and its entry starts with Breaking:. An entry is the subject without its type, the scope in bold and the first letter capitalised:fix(chart): drop a label (#12)becomes- **chart**: Drop a label (#12). PRs are squash-merged with the PR title as the subject, so write the title as a changelog line for an operator;pr-title.ymlchecks it is conventional. The section becomes the GitHub Release notes, and the chart's also becomes the packaged chart'sartifacthub.io/changesannotation (scripts/chart-artifacthub-changes.py), so the script refuses any heading that is not a Keep a Changelog kind. - Checks (
--executeonly) withscripts/check-release-version.sh <tag>, plusscripts/chart-artifacthub-changes.pyfor the chart, then the artifact's make target,make -C ansible galaxy-check-<collection>ormake lint-helm(--no-verifyskips the make target only). A tag whose workflow fails burns its version, so the checks run before anything is committed. - Commits (
--executeonly)chore(release): <tag>and tags<tag>, both signed, and pushes them withgit push --atomic origin main <tag>: origin gets both or neither. If a check or the commit (a pre-commit hook, say) fails, both files are restored and nothing is committed. If tagging or the push fails, or the script is stopped after the commit, the commit (and the tag) stay local, and it says whether origin got anything and how to undo. A refusal before step 5 never touches the working tree.
While PUBLISH_ENABLED is not true, the script says so before it writes anything (it
reads the variable with gh, when it can): the tag push then only builds the artifact,
and the version is used up all the same.
Then watch the Release collection or Release chart workflow (gh run list --workflow release-chart.yml). With publishing enabled, the publish job waits for
approval on the release environment. Release each artifact on its own run; when a
change lands in several, run the script once for each.
Prerequisites.
- git-cliff 2.9 or later, for
require_conventional(cargo install git-cliff; CI tests with 2.14.1). - Git configured to sign, with
user.signingkeyset explicitly. The script signs a throwaway commit object first, so a key that cannot sign fails before the checks. - A clean
mainequal toorigin/main, and no local tag of the artifact above origin's highest. Release a commit whose CI is green. - A bypass of the
mainruleset, because the release commit goes straight tomain: its pull-request rule and its required status checks (pr-titleamong them) both apply to a direct push. - The repository settings the changelog relies on: squash merges only, with the PR title
as the squash commit's subject (Settings → General → Pull Requests, and the
mainruleset's allowed merge methods), andpr-titlea required status check.
A first release. All three artifacts are past theirs. A new artifact starts from
the 0.0.0 placeholder in its manifest and names its level:
scripts/release.sh <artifact> minor --execute cuts 0.1.0. Without a tag, git-cliff
would render the whole history, so the first release does not use it. It renames the
changelog's hand-written ## [Unreleased] section to the dated version instead, so
until then, changes are logged there by hand. After that, no [Unreleased] section is
kept: the script refuses one, because every later section is generated.
The chart's workflow file name (release-chart.yml) and tag shape (decdn-node-X.Y.Z)
are both part of its cosign certificate identity and of its attestations' signer
workflow and source ref (see Verifying a release): never change
either once a chart is published.
Done for all three artifacts: the Galaxy namespace and key, the release environment,
PUBLISH_ENABLED, the public GHCR package and the Artifact Hub listing with its
repositoryID. Kept for a new artifact, or to rebuild the setup.
Before the first publish:
- Galaxy namespace. The
decdnnamespace must exist on galaxy.ansible.com and the account behindGALAXY_API_KEYmust be allowed to publish to it. Add the key as the repository secretGALAXY_API_KEY. releaseenvironment. Create it under Settings → Environments with required reviewers, and putGALAXY_API_KEYthere rather than as a repository secret if you want the reviewer gate to guard it too.- Enable: set the repository variable
PUBLISH_ENABLEDtotrue, then cut the releases as above,node-collectionbeforepublisher-collection(Galaxy resolvesdecdn.publisher's dependency ondecdn.nodeat install). Every artifact sits at the0.0.0placeholder, soscripts/release.sh <artifact> minor --executemakes the first three tagsnode-collection-v0.1.0,publisher-collection-v0.1.0anddecdn-node-0.1.0; their versions diverge from then on.
After the first chart publish (the package does not exist before it):
-
GHCR visibility. The first
helm pushcreates thedecdn/charts/decdn-nodepackage as private. Make it public under the org's Packages settings, or nobody outside the org can pull it, Artifact Hub included. -
Artifact Hub. Sign in to artifacthub.io with the
info@decdn.orgaccount and add a Helm repository with the URLoci://ghcr.io/decdn/charts/decdn-node. That account owns it and Artifact Hub lists the chart from then on. (ownersincharts/decdn-node/artifacthub-repo.ymlis what lets the account claim the repository if someone else added it first.) For the Verified Publisher badge, put the repository ID it shows intorepositoryIDin that file and merge it: every publish pushes the file asghcr.io/decdn/charts/decdn-node:artifacthub.io, so a later publish without the ID would drop it. Artifact Hub re-reads the file only when a new chart version appears, so the badge comes with the next release. To have it with the first one, push the file by hand right after adding the repository, before Artifact Hub first processes it, with a token that haswrite:packages(gh auth refresh -s write:packagesadds it to gh's):gh auth token | oras login ghcr.io --username "$(gh api user --jq .login)" --password-stdin cd charts/decdn-node && oras push ghcr.io/decdn/charts/decdn-node:artifacthub.io \ --config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \ artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml
In both workflows the GitHub Release is the last step, so it only appears once the artifact is live. The two workflows share nothing at run time (each has its own concurrency group), so a failure in the chart's release never affects a collection's, or the other way round.
Re-run only the failed publish job, never the whole workflow: publish reuses the
artifact build checksummed, while a fresh build repackages the chart, which need
not reproduce the same bytes.
Chart (release-chart.yml: the package's attestation, then the chart push and
signature, then the pushed chart's attestation, then a check that verifies all three
as a user would, then the Artifact Hub metadata, then the Release). The pushes are
repeatable: re-pushing the same package moves the version tag to the same digest, a
re-run adds one more signature and one more attestation for the same digest, and
oras push moves the artifacthub.io tag. The Release is not: if gh release create
made the Release and then failed (an asset upload, say), a re-run fails on the existing
Release. Finish it by hand with
gh release upload decdn-node-X.Y.Z --repo decdn/devops <missing assets> from the
release-decdn-node-X.Y.Z workflow artifact.
Collections (release-collection.yml: Galaxy, then the Release). Galaxy refuses a
version that already exists. If the publish step failed before Galaxy accepted the
upload, fix the cause and re-run. If Galaxy did accept it, finish by hand from the
release-<collection>-collection-vX.Y.Z workflow artifact, with the same assets as the
workflow (release-notes.md in it is not an asset):
# decdn.node; --latest=false if a higher node-collection-v release already exists
gh release create node-collection-vX.Y.Z --repo decdn/devops --title "decdn.node collection X.Y.Z" \
--latest=true --notes-file release-notes.md decdn-node-X.Y.Z.tar.gz SHA256SUMS
# decdn.publisher: never Latest
gh release create publisher-collection-vX.Y.Z --repo decdn/devops --title "decdn.publisher collection X.Y.Z" \
--latest=false --notes-file release-notes.md decdn-publisher-X.Y.Z.tar.gz SHA256SUMSNever re-use a version for different content. Cut the next patch version.
The release workflow signs the chart with cosign v3, which stores the signature as a
Sigstore bundle attached to the chart as an OCI referrer. Verify it with cosign v3, or
with cosign v2.6 or later and --new-bundle-format=true; without that flag, cosign v2
does not look there and reports no signatures found.
# The chart's signature: keyless, tied to this repo's chart release workflow and the
# release's own tag.
cosign verify ghcr.io/decdn/charts/decdn-node:X.Y.Z \
--certificate-identity https://github.com/decdn/devops/.github/workflows/release-chart.yml@refs/tags/decdn-node-X.Y.Z \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# The files attached to the GitHub Release.
sha256sum --check SHA256SUMSEvery chart release after 0.1.0 also carries SLSA build-provenance attestations, for the
pushed chart and for the .tgz attached to the GitHub Release
(gh release download decdn-node-X.Y.Z --repo decdn/devops; helm pull gives the same
file). They name the commit and the workflow run that built it, and carry the same
signer identity as the signature, so on those releases cosign verify may list the
attestation too: the signature is the entry of type
https://sigstore.dev/cosign/sign/v1. gh attestation verify needs a logged-in gh.
--signer-workflow alone accepts that workflow run from any branch or tag, so
--source-ref pins the release tag, as the cosign identity above does:
gh attestation verify oci://ghcr.io/decdn/charts/decdn-node:X.Y.Z \
--repo decdn/devops --signer-workflow decdn/devops/.github/workflows/release-chart.yml \
--source-ref refs/tags/decdn-node-X.Y.Z
gh attestation verify decdn-node-X.Y.Z.tgz \
--repo decdn/devops --signer-workflow decdn/devops/.github/workflows/release-chart.yml \
--source-ref refs/tags/decdn-node-X.Y.ZThe pushed chart's attestation is also attached to it on ghcr.io: add
--bundle-from-oci to the first command to read that copy instead of GitHub's.