enable-semantic-release <repo_url>
Wraps enable-semantic-release.py — read its module docstring before
improvising; every check in it exists because it actually bit.
This skill is self-contained: the script and the Job manifest it edits both ship in this directory, and the script resolves the manifest against its own path, never the cwd. Nothing is downloaded at run time.
enable-semantic-release/
SKILL.md
enable-semantic-release.py # forge-agents symlinks this at its root
job-semantic-release.yaml # and at openshift/job-semantic-release.yaml
justfile-block.just # the just targets it installs
agents-md-block.md # the how-to it installs into AGENTS.md
ci-on-pull-request.yaml # templated: repo name, task file, storage
ci-python-ci.yaml # installed verbatim when python is detected
ci-bun-ci.yaml # installed verbatim when bun is detected
ci-commit-title-check.yaml # installed verbatim, always
ci-OWNERS # installed verbatim, always
Those two symlinks are why there is one copy of each file rather than two: edit the version here and the checkout follows. ⚠️ Do not "fix" a drifting copy by replacing a symlink with a file.
Run it by its own path; the commands below use the ./ form that works from a
forge-agents checkout:
"$(dirname "$0")"/enable-semantic-release.py <url> # from anywhere
It does four things, named grant, justfile, agents-md and ci, and
any subset can be run alone with --only / --skip:
grant— the four-part chain the docstring walks through. Needsoc, logged in to the cluster holding namespaceb4mad-forgejo: the admin and agent Secrets are read live, and the Forgejo it grants on is the one in that cluster. Withoutocthis part stops and changes nothing.justfile—preflight,release,release-dry,clean-jobsappended to the target repo's justfile.agents-md— a ten-line "how you release here" appended to its AGENTS.md. It points at the targets rather than restating them.ci— a pull-request CI pipeline (Tekton/Pipelines-as-Code) installed as four whole files:.tekton/on-pull-request.yaml,.tekton/tasks/{python,bun}-ci.yaml(whichever the repo has evidence for),.tekton/tasks/commit-title-check.yaml, andOWNERS. See "Thecifiles" below.
(2) and (3) are managed blocks: same fence, same version, same hash, same
rules, one implementation (class Managed). They need nothing but git, which
is why --skip grant is the half you can exercise anywhere. (4) needs nothing
but git either — see its own section for why it is not a fifth Managed
consumer.
When to use
- "enable semantic-release for
<repo>" / "let the release agent release X" - A release run failed with
Forgejo: User 'b4mad-release-agent' is not allowed to push to branch 'main' - You want to know why a repo is not releasable without changing anything
(
--dry-run) - A repo is already granted and just wants the targets or the docs
(
--only justfile,agents-md)
Do not use it to run a release on a repo that is already enabled — that is
just the oc create at the end.
Input
One argument, an SSH clone URL in exactly this form:
ssh://git@git.b4mad.industries:2222/<owner>/<repo>.git
The form is required, not merely accepted: it is the literal string the Job's
REPO_URL takes, so a URL the script blesses can be pasted into the manifest
unchanged. If the user gives an HTTPS URL or owner/repo, rewrite it to this
form and say that you did.
Instructions
Survey first, always. Run the dry run and show the plan:
./enable-semantic-release.py <url> --dry-runIt changes nothing. Exit 0 = plan printed (or nothing to do), 1 = a precondition failed with an actionable message, 2 is not reachable here.
Read the plan out to the user before applying. Lines marked
[--force]widen access beyond the one repo — say what else they touch. The script spells the blast radius out (— this also grants it on …); do not paraphrase it away.Apply once the user is happy:
./enable-semantic-release.py <url>Exit 2 means the plan contains gated steps and needs explicit consent. Do not re-run with
--forceon your own — go back to the user with what the gated step widens, and let them say yes../enable-semantic-release.py <url> --force # only after the user agreesHand over the release command. On success the script prints the
sed …| oc create -f -that starts the Job. Print it; do not run it. The grant and the run stay separately auditable. Drop the third-efor a dry-run release. Once the targets are installed,just releaseis the same Job with the preflight in front of it — say so, still do not run it.
Managed blocks
Both installed blocks obey the same rules, from the same code (class Managed). Only three things differ per block: the comment syntax, the
candidate filenames, and what counts as a clash.
The just targets
Installed into the git top level of the cwd (--repo-dir DIR to aim
elsewhere), into justfile, Justfile or .justfile — whichever is there, a
lower-case justfile if none is:
| Target | What it does |
|---|---|
preflight |
Every precondition, read-only. Also what release depends on. |
release-dry |
A Job with --dry-run: no tag, no commit, no Release. |
release |
The real thing. |
clean-jobs |
Delete this repo's finished release Jobs, after a prompt. |
Variables are prefixed sr_; there is no set shell and no default recipe,
because the block is appended to a file that may already have both and just
rejects a duplicate setting. Nothing in the block is machine-specific: the slug,
the manifest and the script are all located at just time.
forge-agents runs this block itself — its Justfile is a header, a default,
and then the fence. Edit justfile-block.just, never the installed copy.
The AGENTS.md how-to
Ten lines: the Job releases the release remote's main from the forge — that
is upstream when it exists, else the current branch's tracking remote, else
origin; preflight warns when origin is the only candidate, because that
is what an un-configured fork looks like. Printed by just preflight and
overridable with just sr_remote=<name> … — and never sees your
working tree, just preflight before just release, commits must be
conventional. It points at the just targets; it does not restate them. Edit
agents-md-block.md.
Which file. AGENTS.md first, then CLAUDE.md; whichever exists, and
AGENTS.md created if neither does.
- only
CLAUDE.mdexists → written toCLAUDE.md. That is the file that repo's agents actually read. - both exist and one is a symlink to the other (forge-agents' arrangement) → one file, written once.
- both exist as separate files →
AGENTS.mdis written and the run prints a loud⚠️ NOTEthatCLAUDE.mdwas not touched. It never writes two files: two copies of one instruction set is how they come to disagree, and this tool can only keep one of them honest.
The ci files
Managed splices a block into a file it does not own — the whole point is
that other, hand-written content survives on both sides. The four ci
targets are not like that: .tekton/on-pull-request.yaml, one of
.tekton/tasks/python-ci.yaml / .tekton/tasks/bun-ci.yaml,
.tekton/tasks/commit-title-check.yaml, and OWNERS are each installed
whole — there is no splice, because there is nothing else in these files
that isn't this skill's. A second implementation of the six-answer decision
(class WholeFile) covers them, reusing the same body_hash/version_key
free functions Managed uses rather than duplicating the version/hash logic —
the question ("is this mine, is it current, is it edited, is it newer") is
identical, only the marker is a single header line at the top of the
file (# >>> enable-semantic-release v… sha256:… (managed file — do not edit by hand) >>>) instead of an open/close fence, and a stale copy is replaced
whole rather than spliced in place.
| Situation | What happens | Exit |
|---|---|---|
| file absent | creates it, header + content | 0 |
| file present, no header (hand-written or foreign) | ⚠️ writes nothing | 1 |
| header present, same version, hash matches | no-op | 0 |
| header present, older version, hash matches (unedited) | replaces the whole file, no flag needed | 0 |
| header present, newer version than this skill | ⚠️ that would be a downgrade: writes nothing | 2 |
| header present, hash does not match its version | ⚠️ you edited it: writes nothing | 2 |
any of the three ⚠️ rows with --force-block |
writes anyway | 0 |
The one place this differs from Managed: a whole-file clash (no header at
all) is exit 1 like a justfile clash, but — unlike a justfile clash —
--force-block does lift it. A justfile clash is fatal-without-a-flag
because appending past it would define a name twice and break just's
parse; a whole-file install never appends, so overwriting a hand-written copy
has nothing else in the file left to break. The exit code (1, matching a hard
clash) signals "this needs a human's attention", not "no flag can help".
Language detection, for choosing python-ci.yaml vs bun-ci.yaml, is
evidence-based and never guesses: package.json or a bun.lock/bun.lockb
→ bun; any of pyproject.toml, setup.py, setup.cfg, requirements*.txt
→ python on its own — a packaging file is sufficient even before the repo has
any tests, since python-ci.yaml's unittest discover -s tests just finds
nothing to run rather than breaking. A repo with no packaging file at all
— stdlib scripts and nothing to pip install, forge-agents' own shape —
still counts as python if it has a tests/ directory and at least one
*.py file at the repo root; tests/ alone does not, since an empty or
unrelated tests/ proves nothing by itself. Both bun and python evidence
present, or neither, refuses outright (exit 1) rather than picking one — a
wrong guess here is a pipeline that is silently always red. --repo-dir
controls where the evidence is looked for, same as everywhere else.
Templating. .tekton/on-pull-request.yaml is the one file with
placeholders: the PipelineRun name and the pipelinesascode.tekton.dev/task
annotation are filled in from the detected language, and the PVC storage
request (1Gi python / 2Gi bun — go through a checkout without a lockfile
to reproduce). The repo name comes from the origin remote's slug, falling
back to the --repo-dir directory's own basename when there is no remote yet
(a fresh git init, or a scratch dir). .tekton/tasks/commit-title-check.yaml
is deliberately not templated — it is meant to read byte-identical
everywhere, matching the hand-copied original at
agentic-forges/forgejo-mcp/.tekton/tasks/commit-title-check.yaml, because
PaC resolves task references from the pipeline's own annotation and cannot
reach into another repo, so a shared task has to be a literal copy kept in
sync by hand rather than a reference.
OWNERS is deliberately NOT templated with human names. It ships with
exactly one entry, b4mad-release-agent — because granting it is the whole
point of this skill, and its release push is itself a CI-triggering event
that needs the same authorisation any other push does. The script does not
guess your human approvers from the git remote's owner segment: that segment
is an org or a fork target, not necessarily a person with review authority,
and a wrong guess in an access-control file is worse than an empty one. The
installed file carries a ⚠️ comment and two commented-out placeholder lines
telling you to add yourself and your co-maintainers before relying on it.
What ci does NOT do. It does not create the Pipelines-as-Code
Repository CR that binds the repo to the cluster — oc get repository -A
either lists this repo or it does not, and if it does not, the installed
pipeline is inert until a human (or a deliberate agent) adds it under
GitOps, same as any other repo onboarding to PaC. It does not touch branch
protection. It does not run a release, or anything else grant does — ci
and grant are independent parts and neither implies the other.
Markers
# >>> enable-semantic-release v1.2.3 sha256:6e54f7c098a2 (managed block — do not edit) >>>
<!-- >>> enable-semantic-release v1.2.3 sha256:6e54f7c098a2 (managed block — do not edit) >>> -->
Same grammar, wrapped in whatever hides a line from the parser: # for a
justfile, an HTML comment for Markdown, where # is a heading and not a comment
at all. The closing line repeats both facts:
<!-- <<< enable-semantic-release v1.2.3 sha256:6e54f7c098a2 <<< -->
Both facts, on both lines, because two different questions get asked later: the version answers "is there a newer block available", the hash (sha256 of the body, first 12) answers "is this still mine to replace". Repeating them on the closing line makes a half-deleted fence a mismatch rather than a silent truncation, and the reader regex is built from the writer's format string so the two cannot drift.
⚠️ This deviates from the Beads <!-- BEGIN BEADS INTEGRATION v:1 … hash:… -->
fence already in this repo's AGENTS.md, whose END line is bare. Deliberate: a
bare END makes a deleted END indistinguishable from a deleted block, and this
skill's two fences must be recognisable as the same fence across two file
types — matching beads in Markdown would have made the Markdown fence look like
beads' and unlike this skill's justfile fence.
SKILL_VERSION in enable-semantic-release.py is the only place a version is
written. Nobody bumps it by hand: .releaserc's prepareCmd seds the release
version in just before tarring the skill, so a released tarball carries its own
number while a forge-agents checkout honestly reads 0.0.0-dev — which makes
every installed release look newer than the checkout, which it is.
What it does in each case
| Situation | What happens | Exit |
|---|---|---|
| file absent | creates it containing the block | 0 |
| file there, no block, no clash | appends the block | 0 |
| file there, no block, clash | ⚠️ writes nothing, names what it found and where | 1 or 2 |
| block, same version, hash matches | no-op, says so | 0 |
| block, older version, hash matches | replaces it in place, automatically — no flag; surrounding content untouched | 0 |
| block, newer version than this skill | ⚠️ that would be a downgrade: writes nothing | 2 |
| block, hash does not match its version | ⚠️ you edited it: writes nothing | 2 |
either of those two with --force-block |
writes anyway; the older content / your edits are lost | 0 |
| fence broken (one marker, or markers disagreeing) | refuses; repair by hand | 1 |
Downgrades
An unedited older block is replaced with no flag. An unedited newer one is
not: that is a downgrade, and the commonest way to reach one is running a
forge-agents checkout — 0.0.0-dev, which is no release at all and sorts below
every real version — over a repo that installed a released tarball. Install the
newer skill and run that, or --force-block to go back deliberately.
Only MAJOR.MINOR.PATCH is compared; prerelease and build metadata are dropped,
because 2.0.0-rc.1 ships the same block as 2.0.0. Whether to rewrite is
still exact string equality, so the marker always names the version that wrote
it — a 2.0.0 skill over a 2.0.0-rc.1 block rewrites the marker and nothing
else.
Clashes
"Something like this is already here, hand-written." The question is the same; the evidence and the consequence are not.
justfile — names, and it is fatal (exit 1). A line scan for recipe, alias
and variable names. --force-block does not override it: appending would
define release twice and just would then refuse to parse the file at all, so
a flag would buy a broken justfile. Rename yours and re-run. Over-eager costs a
warning; under-eager costs an unparseable file, so it errs eager. It misses
names pulled in by import/mod.
AGENTS.md — content, and it is soft (exit 2). Markdown has no namespace, so
there is nothing to collide with mechanically. Two signals, outside the managed
block: a heading matching /releas/i, or any line naming the machinery (just release, just preflight, release-dry, semantic-release).
⚠️ Signal two is deliberately over-eager. forge-agents' own AGENTS.md trips
it on a heading about its bead database, which is a false positive and the right
direction to fail in: a spurious warning costs one --force-block, a spurious
append costs a file that silently contradicts itself about how the repo
releases, in the one document agents are told to trust. The known false negative
is a file that explains releasing without naming any tool ("push to main and the
bot does the rest") — nothing short of reading it catches that, and an agent
running this skill is expected to have read it.
Flags
| Flag | Meaning |
|---|---|
--dry-run |
plan every part, change nothing. Excludes --force*. |
--force |
permit grant steps that widen access beyond this repo |
--force-block |
permit replacing a hand-edited managed block, or appending past a soft clash |
--only PARTS |
comma-separated subset of grant,justfile,agents-md,ci |
--skip PARTS |
everything except those |
--repo-dir DIR |
which repo's files (default: git top level of the cwd) |
--only/--skip rather than a --no-X and an --X-only per part: three parts
would have meant six such flags, and the fourth (ci) would have meant eight.
One pair that takes part names says the same thing and composes — --only agents-md --force-block scopes a force to one file. just preflight calls the
script with --only grant, so it cannot rewrite the justfile it is running
from.
⚠️ --force and --force-block are separate on purpose and must stay separate:
one consents to widening a bot's access on the forge, the other to losing your
own words in a local file. Folding them would make asking for the second
silently authorise the first.
Tests
just test — stdlib unittest, in tests/, 103 cases covering all three
consumers: round-trip writer→reader across both Managed comment syntaxes and
seven version strings, fence integrity, all seven planner actions, --dry-run
writing nothing in every one of them, the splice preserving content on both
sides, the AGENTS.md file-choice matrix, the clash asymmetry (justfile's fatal
vs. AGENTS.md's and ci's liftable), version ordering, the ci part's
WholeFile lifecycle (create/clash/no-op/update/downgrade/edited, each with
and without --force-block), its language detection (both languages, and the
ambiguous/neither refusals), and — where just is installed, skipped cleanly
otherwise — that generated justfiles actually parse.
Hermetic: scratch dirs only, no cluster, no network. The grant path is not
covered; it needs a live Forgejo and a live Secret, and a mock forge would test
the mock. tests/ lives outside skills/, so it does not ship in the tarball.
⚠️ preflight does not run them: preflight is part of the shared block, and a
repo that merely installs this skill has no tests to run. In forge-agents, just test is a separate hand-maintained target above the fence.
Failure modes and what they mean
| What you see | What it is |
|---|---|
no SSH key / no GPG key |
Run ./create-forge-agent.py b4mad-release-agent --overwrite. A missing GPG key does not fail the push — it lands the release commit unverified, silently. |
Secret forgejo-agent-… is missing |
The SealedSecret is not in the b4mad-forgejo kustomization; see openshift/README.md. |
has no semantic-release config |
The repo needs a .releaserc. Nothing to enable yet. |
org '<x>' has no 'release-engineering' team |
Copy the team shape from agentic-forges using the curl the script prints. It must carry repo.code=write — a team that is nominally "write" without that unit still rejects the push. |
'<x>' is a personal account |
Forgejo has no teams there. The script offers transfer-to-org (preferred) or a direct collaborator grant, with the cost of each. Present both; the choice is the user's. |
enable_push=false |
Branch protection forbids direct pushes outright. The script will not disable a protection — that is a hand edit or a move to PR/AGit flow. |
Rules
- Never pass
--forceor--force-blockwithout the user's explicit go-ahead in this session.--force-blockdestroys work someone did by hand; show them a diff of the block first if they want one, and scope it with--onlyso it cannot reach a part they have not looked at. - Never edit an installed managed block. Edit
justfile-block.justoragents-md-block.mdand re-run. An edited block is exactly what the hash exists to catch. - An
agents-mdclash is a prompt to read the file, not to force past it. If it already says how to release, the right answer is usually to delete the hand-written section, not to append a second one. - Never edit branch protection by hand to work around a refusal. If the script declined, it declined for a reason written in the docstring.
--dry-runand--forceare mutually exclusive; the script rejects the pair.- A
ciclash (an existing.tekton/*.yamlorOWNERSwith no enable-semantic-release header) is a prompt to read the file before reaching for--force-block— it means the repo already has hand-written CI or an ACL there, and overwriting it is a bigger loss than an AGENTS.md append: there is no splice, so the whole file goes. - Never invent human names for
OWNERS. Add yourself and your co-maintainers by hand; the script deliberately ships it with onlyb4mad-release-agent.