fix(release): preserve backtick-delimited content via --notes-file

Summary

The release catalog component drops backtick-delimited content from every release-page description. The cause is shell eval: the release: keyword's description: field is shell-eval'd before glab sees it, and backticks become command substitution. This MR drops the release: keyword and invokes glab release create --notes-file directly with the annotated tag body, mirroring the pattern this catalog already uses for its own create-release job.

The fix also incidentally repairs re-run behavior: glab release create updates an existing release by default, but the release: keyword wrapper always passes --no-update and fails on retry. By calling glab directly we inherit the updating default.

The bug

templates/release/template.yml carries the broken description-from- variable form:

release:
  tag_name: $CI_COMMIT_TAG
  name: "${CI_PROJECT_TITLE} ${CI_COMMIT_TAG}"
  description: "${CI_COMMIT_TAG_MESSAGE}"

Trace from kaniko v1.0.1 (job 14510637450, https://gitlab.com/gitlab-com/public-sector/kaniko/-/jobs/14510637450):

/bin/sh: eval: line 198: release-create: not found
/bin/sh: eval: line 198: release: not found
/bin/sh: eval: line 198: vd+.d+.d+: not found
/bin/sh: eval: line 198: refs/tags/v1.0.1: not found
/bin/sh: eval: line 198: :v1.0.1: not found

Reproduced locally with a minimal shell harness — every backticked token in the tag body is parsed as a command lookup and stripped from the description that reaches the release page; the dot/backslash mangling (vd+.d+.d+ from `^v\d+\.\d+\.\d+$`) is the giveaway that double-quoted variable substitution + shell eval is in the loop.

Secondary failure: re-running a release job on the same tag hit release for tag "v1.0.1" already exists and --no-update flag was specified. The release: keyword always invokes glab with --no-update; consumers couldn't retry without manually deleting the release page first.

Candidate fixes considered

A — Keep release: keyword, point description: at a file path. The release:description: keyword reads file contents when the value is a single word matching an existing file in $CI_PROJECT_DIR (release-cli's behavior, inherited by the glab-backed release: keyword). This would have worked, but it does not unblock the --no-update retry failure: the keyword wrapper always passes --no-update. Rejected.

B — Drop the keyword, invoke glab release create --notes-file directly. Bypasses the eval entirely (file bytes go through unparsed) AND drops --no-update, so re-runs refresh the existing release instead of failing. Mirrors this catalog's own .gitlab-ci.yml create-release job, which has used glab release create --notes-file /tmp/release-notes.md since v2.x without incident. Chosen.

C — Pre-process CI_COMMIT_TAG_MESSAGE to escape backticks. Inelegant; preserves the foot-gun for the next escape character that trips eval ($, ", \). Rejected.

Chosen approach

templates/release/template.yml now:

  1. Sets GLAB_ENABLE_CI_AUTOLOGIN=true so glab authenticates via CI_JOB_TOKEN through the gitlab.JobTokenAuthSource path (same as the catalog's own release job).
  2. Fetches the annotated tag (git fetch origin refs/tags/<tag>:refs/tags/<tag> --force) so %(contents) is available on shallow clones.
  3. Reads the body via git tag -l --format='%(contents)', strips any PGP signature block (for git tag -s signed tags), trims trailing blank lines, writes to /tmp/release-notes.md.
  4. Warns (non-fatal) if the body is empty so lightweight-tag misuse shows up in the job log.
  5. Calls glab release create "${CI_COMMIT_TAG}" --name "..." --notes-file /tmp/release-notes.md --ref "${CI_COMMIT_SHA}". Updates existing releases by default.

No input surface change. The upload-release-assets job is unchanged.

Lab validation

  • Local backtick reproduction — built a minimal sh harness that emulates the description: "${VAR}" evaluation path. With the broken pattern, a tag body containing `release-create`, `^v\d+\.\d+\.\d+$`, and `refs/tags/v1.0.1` produced the exact trace lines seen in the kaniko job (the vd+.d+.d+ token in particular is diagnostic). With the file-based pattern (printf '%s\n' "${VAR}" > /tmp/notes.md; cat /tmp/notes.md), the body round-trips byte-for-byte.

  • python3 scripts/lint-templates.py — all 17 templates pass the v2 canonical shape; the modified release/template.yml is green.

  • glab ci lint .gitlab-ci.yml — root pipeline yaml is valid.

  • Consumer-project lab — pending pre-push approval. Plan: pin a throwaway consumer project to this branch's SHA, push a tag whose annotation body contains backtick-delimited code spans, observe the release page renders the spans intact, retry the job and confirm the release page is refreshed without a release already exists failure. Will surface results in the MR before merge.

Breaking change assessment

Not breaking for consumers on v3.x:

  • Input surface unchanged: stage, job_name, package_name, binary_job, attest_job, container_image — same names, same defaults.
  • Job names unchanged: create-release (override-able via job_name) + upload-release-assets.
  • Output unchanged: release page with the same description, name, tag, and asset links.
  • New observable behavior — re-runs on existing tags now succeed (previously failed). This is strictly additive. Existing consumers that worked before continue to work; consumers that hit the --no-update failure now pass through.

Lands under [Unreleased]### Fixed per Keep a Changelog. Will ship in the next minor (3.4.0 per the in-flight feat/v3.4.0-prep branch, or a backport to a 3.3.x patch if the team decides the backtick fix can't wait for the v3.4.0 cut). Recommend the team treat this as a patch release given the strictly additive behavior change; the new behavior is closer to "what consumers always expected" than to "new contract surface".

Per the public-sector reference's standards/release SKILL, clause 3: "Surfaces the tag body verbatim as the release description. The pipeline component reads CI_COMMIT_TAG_MESSAGE and passes it straight through; no template lives in the pipeline." The pre-fix behavior violated this clause silently — the body reached the page minus its backticked content. The fixed component restores the verbatim contract.

The SKILL itself does not need updating; its inline-fallback example block still works for projects that haven't migrated to the catalog component (the failure mode is the same upstream, and the workaround is the same — switch to a script:-based glab release create --notes-file call). Worth a follow-up to the SKILL to call out the backtick foot-gun in the inline fallback prose, but out of scope for this MR.

Known consumers

pipeline/release is included at v3.0.0 by these public-sector projects (verified by grep across the local mirror of the public-sector estate; namespace-resolved hits):

  • assaygitlab.com/gitlab-com/public-sector/pipeline/release@v3.0.0
  • manifold$CI_SERVER_FQDN/gitlab-com/public-sector/pipeline/release@v3.0.0
  • postern$CI_SERVER_FQDN/gitlab-com/public-sector/pipeline/release@v3.0.0
  • tach$CI_SERVER_FQDN/gitlab-com/public-sector/pipeline/release@v3.0.0

And one project on the older namespace path:

  • posturegitlab.com/gitlab-com/public-sector-tools/pipeline/release@v2.5.0

kaniko does not include the catalog component; it carries its own inline release-create job in .gitlab-ci.yml lines 466-497 with the same release: description: '$CI_COMMIT_TAG_MESSAGE' bug. The catalog fix here does not propagate to kaniko automatically. Follow-up MR on kaniko required to either adopt the catalog component or apply the same --notes-file pattern inline.

Test plan

  • Pin a consumer project (suggest: tach on a throwaway branch) to this MR's HEAD SHA and push a v0.0.0-bt-test annotated tag with a body containing backtick-delimited code spans (`vX.Y.Z`, fenced blocks, refs).
  • Confirm the release page renders the backticked content verbatim — no eval lines in the job log, no missing prose on the page.
  • Retry the same release job. Confirm it succeeds with "release updated" semantics instead of failing with --no-update.
  • Confirm upload-release-assets still attaches the package registry links (regression check — the asset-link API is unchanged but the needs: <job_name> dependency now waits on a job that runs more shell, so dependency ordering matters).
  • Once the v3.4.0 prep branch lands or a patch tag is cut, bump kaniko (separate MR) to either consume the catalog component at the new tag OR apply the same --notes-file pattern inline.

Out of scope

  • Updating consumer pins. Each consumer bumps to the new tag on its own cadence per the catalog's normal Renovate flow.
  • Fixing the kaniko inline release-create job. Tracked above as a follow-up MR.
  • Updating standards/release SKILL prose to call out the backtick foot-gun in the inline fallback example. Tracked as a follow-up.

Commits on this branch:

  • fix(release): preserve backtick-delimited content via --notes-file
  • docs(changelog): release backtick fix + re-run idempotency

🤖 Generated with Claude Code

Merge request reports

Loading