| name | release-publisher |
| description | Publishes a GitHub release on version bump — reads version from package manifest, creates signed tag (`git tag -s`), runs `gh release create --generate-notes`, attaches Sigstore attestations, auto-detects `-rc/-beta/-alpha` as pre-release. Invoke on version-bump merge or "publish release". Inline. |
| allowed-tools | Bash, Read |
| context | inline |
release-publisher — Tag, sign, publish, attest
changelog-updater writes the file. release-publisher turns it into a GitHub Release with signed tag + attestations. The missing last mile.
Inputs
VERSION: [semver — e.g., "2.5.0"; derived from package manifest]
TAG_PREFIX: [v | "" — default v, yielding "v2.5.0"]
CHANGELOG_PATH: [default: CHANGELOG.md]
PRE_RELEASE: [auto-detected from version suffix (-rc.N, -beta.N, -alpha.N)]
SIGN: [true | false — default true if GPG/SSH signing key configured]
ATTACH_ATTESTATIONS: [true | false — default true if cicd-security-hardener detected Sigstore]
TARGET_COMMITISH: [default: main]
Auto-inference sources
- VERSION → try in order:
package.json → jq -r .version package.json
pyproject.toml → grep ^version = "..."
Cargo.toml → grep ^version = "..." under [package]
build.gradle.kts → grep version = "..." or version = line
go.mod → no native version; fall back to latest tag bump
- PRE_RELEASE →
echo "$VERSION" | grep -qE '-(rc|beta|alpha)\.[0-9]+$' → true
- SIGN →
git config --get user.signingkey returns non-empty
Preflight
gh auth status 2>&1 | grep -q "Logged in" || exit 1
git symbolic-ref --short HEAD | grep -qE "^(main|master)$" || { echo "Not on main/master"; exit 1; }
git status --porcelain | grep -q . && { echo "Working tree dirty"; exit 1; }
git fetch origin --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse "origin/$(git symbolic-ref --short HEAD)")
[ "$LOCAL" = "$REMOTE" ] || { echo "Not up-to-date with origin"; exit 1; }
TAG="${TAG_PREFIX:-v}${VERSION}"
git rev-parse "$TAG" > /dev/null 2>&1 && { echo "Tag $TAG already exists"; exit 1; }
Process
1. Read version from manifest
if [ -f package.json ]; then
VERSION=$(jq -r .version package.json)
elif [ -f pyproject.toml ]; then
VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | sed -E 's/.*"(.+)".*/\1/')
elif [ -f Cargo.toml ]; then
VERSION=$(grep -E '^version\s*=' Cargo.toml | head -1 | sed -E 's/.*"(.+)".*/\1/')
else
echo "No manifest found — provide VERSION explicitly"
exit 1
fi
TAG="${TAG_PREFIX:-v}${VERSION}"
2. Verify CHANGELOG entry exists
grep -qE "^##?\s*\[?${VERSION}\]?" "$CHANGELOG_PATH" || {
echo "No CHANGELOG entry for $VERSION — run changelog-updater first"
exit 1
}
3. Create signed tag
TAG_MSG=$(awk -v v="$VERSION" '
$0 ~ "^##?[[:space:]]*\\[?" v "\\]?" {flag=1; next}
/^##?[[:space:]]*\[?[0-9]+\.[0-9]+\.[0-9]+/ && flag {exit}
flag {print}
' "$CHANGELOG_PATH")
if [ "$SIGN" = "true" ]; then
git tag -s "$TAG" -m "Release $TAG
$TAG_MSG"
else
git tag -a "$TAG" -m "Release $TAG
$TAG_MSG"
fi
git push origin "$TAG"
-s uses the configured signing key (GPG or SSH via gpg.format=ssh).
4. Create the GitHub Release
RELEASE_FLAGS="--target $TARGET_COMMITISH --generate-notes --notes-file /tmp/ciel-release-notes-$$"
[ "$PRE_RELEASE" = "true" ] && RELEASE_FLAGS="$RELEASE_FLAGS --prerelease"
[ "$PRE_RELEASE" = "false" ] && RELEASE_FLAGS="$RELEASE_FLAGS --latest"
{
echo "$TAG_MSG"
echo ""
echo "---"
echo ""
echo "<!-- gh auto-generated section below -->"
} > /tmp/ciel-release-notes-$$
gh release create "$TAG" $RELEASE_FLAGS --title "$TAG"
--generate-notes appends auto-generated PR list. Our CHANGELOG entry gives human context.
5. Attach Sigstore attestations (if applicable)
If cicd-security-hardener workflow produced .intoto.jsonl attestations (SLSA Level 3):
RUN_ID=$(gh run list --workflow=release.yml --branch=main --status=success --limit=1 --json databaseId --jq '.[0].databaseId')
if [ -n "$RUN_ID" ]; then
gh run download "$RUN_ID" --pattern "*.intoto.jsonl" --dir /tmp/ciel-attestations-$$
for att in /tmp/ciel-attestations-$$/*.intoto.jsonl; do
gh release upload "$TAG" "$att"
done
fi
Modern alternative: gh attestation subcommand (GitHub CLI v2.46+) verifies + attaches in one step:
gh attestation verify <artifact> --repo "$OWNER/$REPO"
6. Emit output
[RELEASE PUBLISHED]
Tag: v2.5.0 (signed: yes, signing key: <fingerprint>)
Release URL: https://github.com/<org>/<repo>/releases/tag/v2.5.0
Pre-release: no
Attestations attached: 3 (.intoto.jsonl)
CHANGELOG entry linked: yes
Post-release suggestions:
- Announce (Slack/discord/mailing list) — outside Ciel's scope
- Bump version on main to next-dev (e.g., 2.5.1-dev)
Guardrails
- Never release without CHANGELOG entry — hard gate. Run
changelog-updater first.
- Never tag unsigned for a non-pre-release — if SIGN=false AND PRE_RELEASE=false, warn loudly. Supply-chain integrity baseline.
- Never overwrite existing tag — if tag exists, refuse. User must bump version or delete the tag explicitly.
- Pre-release detection is authoritative —
-rc, -beta, -alpha suffixes force --prerelease. No "I know better".
- Never release from a detached HEAD — must be on main/master.
- Never release with uncommitted changes — preflight dirty-tree check.
- Never release behind origin — if origin has commits you don't, abort (you'd miss changes).
- Attestation attachment is best-effort — if
gh run download fails, release still completes; log the miss for /ciel-improve.
- Don't announce automatically — posting to Slack/Discord/mailing lists is out of scope. Hand off to user.
When triggered
- Post-merge of a version-bump PR (detected by
package.json diff touching the version field)
- User says: "publish release", "create tag v2.5.0", "ship the release"
- After
changelog-updater on a release-labeled PR
- Skipped if PR body contains
[skip release]
Anti-pattern
❌ git tag 2.5 && git push --tags && "done" # unsigned, no release, no notes
✅ release-publisher → signed tag → gh release create → attestations attached
❌ gh release create --latest on a -rc version # marks RC as latest stable
✅ Auto-detect -rc → --prerelease
Handoff
- Upstream:
changelog-updater (populates CHANGELOG.md for the release notes)
- Upstream:
cicd-security-hardener (configures CI to emit Sigstore attestations)
- Downstream: user announces release through their own channels (Ciel doesn't auto-post)
References
gh release create — cli.github.com/manual/gh_release_create
gh attestation verify — cli.github.com/manual/gh_attestation
- Sigstore keyless signing — docs.sigstore.dev/cosign/signing/signing_with_keyless
- Signed tags (
git tag -s) — git-scm.com/docs/git-tag
- SLSA Level 3 provenance — slsa.dev/spec/v1.0/levels#build-l3
- Ciel pipeline: pr-merger → issue-closer → branch-cleaner → changelog-updater → release-publisher