All notable changes to this project are documented here.
The format follows Keep a Changelog, and the project adheres to Semantic Versioning.
This file starts at 0.12.1. Earlier releases are on the releases page; their notes were not backfilled here rather than reconstructed after the fact.
1.2.2 - 2026-09-27
references/release-process.mdhas a Phase 1 subsection "A release is a milestone — batch related changes into one": related changes go into one release, formatting alone does not justify a patch release, and a change arriving less than an hour after the previous release waits for the next one. It also says whatvalidate-pre-release.shchecks for the green-CI precondition: a failed run of HEAD fails, unfinished or missing runs only warnreferences/release-process.md, "Crediting contributors", asks for the bare@logininCHANGELOG.mdas well. The section is copied into the release body, where[@login](https://github.com/login)arrives as a plain link, which is not a mention and notifies nobody. A profile link stays fine inREADME.mdandCONTRIBUTING.md, which are never copied into a release body- the TER preflight of
/release(commands/release.md) checks both placesTYPO3_TER_ACCESS_TOKENcan come from.gh secret listshows repository secrets only, and an organization secret withvisibility: selectedresolves empty in every repository not on its list; the step now also lists the repository's organization secrets withgh api --paginate "repos/{owner}/{repo}/actions/organization-secrets?per_page=100", and has an org admin confirm the organization secret exists before adding the repository to its selected repositories
release-status.sh -R owner/reporun outside that repository's checkout reads the version files from the named repository's default branch (through the contents API,.tocmanifests one level deep through the git trees API) before it falls back to the latest release. It read only the current directory: run from a parent directory right after a tag push, it took the previous release's version, and--watchwaited on that release's finished run and answeredNEXT: ok. The output names the source,--jsonreportsversion_source: remotetogether withpackageandextension_key, andSKILL.mdsays so under "Start Here"release-status.sh -Rno longer reads a checkout of a different repository, whose files describe another project and were read without any note. A git checkout counts as foreign when every remote is a GitHub URL and none names the repository (compared case-insensitively, so a fork with anupstreamremote still matches); a remote that names nogithub.com, such as an SSH host alias, keeps the local files in userelease-status.shreports a default branch whose version files lag the latest release as such, and gives the stale-worktreegit switchadvice only for local files, since it acts on the current directory. A failed git trees request no longer ends the scriptvalidate-pre-release.shgrades every CI run of HEAD on the current branch (gh run list --branch … --commit …) instead of the newest run of any workflow on any branch, so a redmainno longer passes because a feature branch is green. Any unsuccessful completed run fails the check; unfinished or absent runs, a list that fills the 500-run limit, and a failed lookup warnrelease-notes-status.shcounts an@loginas credit only outside an inline link; a mention inside a code span still counts. A contributor credited only through a link such as[@login](https://github.com/login)or[by @login](url), or matched only inside@alicebobormail@alice.dev, is now reported as missing; a bare mention next to a link, as in([#43](url) by @login), still counts
1.2.1 - 2026-09-24
release-status.shreads the top-level version ofcomposer.jsonandpackage.json(apackage.jsonmarked private is skipped, as invalidate-pre-release.sh). A repository stating its version only there, such as netresearch/assetpicker, was treated as versionlessrelease-status.shfinds a tag with and without thevprefix, the latest release's spelling first, and suggests a missing tag in the spelling the repository already uses. It looked only forv2.0.1and reported the bare tag2.0.1absent, which also sent the Packagist check to the wrong version- a leading
vin the declared version ("version": "v2.0.1", which Composer allows) no longer makes the tag lookup ask forvv2.0.1
1.2.0 - 2026-09-24
references/release-process.mdkeeps the CI-appended blocks of the library and source-archive orchestrators, too. The capture recipe cut only at## Container image, the first block ofrelease-go-app.yml; a TYPO3 extension release starts its CI blocks with## Installation, so the cut captured nothing and the overhaul would have dropped them. It now cuts at the first of## Installation,## Container imageand## Verify your download, the headingsrelease-notes-status.shalready checks, and says to look at the tail before publishing- the same recipe captures the body with
jq -j .body.gh … --jq .bodyappends a newline the stored body does not have, and the recipe published it: 8054 bytes against the stored 8053 on nr-llm v0.36.0 references/release-process.mdhas a section on 0.x minor releases: every consumer in the target installation's lock that pins the previous minor refuses the new one, and each needs a widened constraint and a release of its own first. It gives thejqquery overcomposer.lockthat lists themguard-gh-release.pyreads agh apicall as the argument list the shell hands to gh (shlex), instead of matching its text. A mutating call on a release endpoint is one with an explicit POST, PUT, PATCH or DELETE, a method the guard cannot read ("$M", empty), or data flags without GET or HEAD. Flags that take a value consume it in every form gh accepts: separate, attached, after=, and inside shorthand groups such as-iXDELETE. Unbalanced quotes around a release path block
references/recovery-procedures.mdhas a section "Release Titles Differ From the Tag":--title "$TAG"in the release workflow as prevention, and as recovery a script the maintainer runs, dry run by default, which renames only exact matches on--apply, reads each title back and ends with a check that no title differs from its tag. The guard blocks title edits, so the agent hands the script over rather than running it
guard-gh-release.pyblocked a mutatinggh apicall on a release endpoint only in the forms its regexes knew. A quoted path, a flag value with blanks, a long data flag, a full URL,repos/$R/releaseswith owner and repo in one variable, therepos/{owner}/{repo}placeholders and the numericrepositories/<id>/releasesroute all passed. One of those regexes also backtracked exponentially: 20 dash-words took over 20 s, where 2000 now take about 15 ms- a trailing shell comment can no longer hide a method:
gh api … -X DELETE # -X GETis blocked. The call is judged with and without the comment cut off, and blocks if either reading does. A release path or method only in a comment, or an apostrophe in such a comment, therefore blocks too - the invocation splitter both guards share no longer cuts a word at a brace (
repos/{owner}/{repo},${VAR}), separates at bash 5.3's${ cmd; }, and reads an assignment prefix whose value holds${…}, a quoted span or an escaped character with blanks.A="x y" gh release create v1.2.3anda='x y' git tag v1.2.3passed both guards before; the second finding came from CodeRabbit
1.1.0 - 2026-09-23
guard-gh-release.pyallowsgh release createwhen--verify-tagis set. gh's own help (2.100.0) reads "Abort in case the git tag doesn't already exist in the remote repository", so the invocation cannot create the lightweight tag that the block exists to prevent — it can only publish a tag that was pushed on purpose, andguard-lightweight-tag.pyis what keeps that tag annotated and signed.--verify-tag=falseand--verify-tag=0stay blocked, and a mention of the flag inside a quoted argument is not the flag. Without the exemption the guard blocked the one correct step in a repository whose supply-chain workflows listen onrelease: published: that event never fires for a release created withGITHUB_TOKEN, so nothing but a human credential can start the provenance and SBOM jobs, and the release had to be published around the guardrelease-status.shderives the version from the latest release where the repository states it in no manifest. Every phase of the verdict keys off the declared version, soprepare-release — no version file foundshort-circuited all of them and reported a version-file question to a repository that has no version file by design; the output now names where the version came from. A repository with neither a manifest nor a release still gets the original verdict, manifest list included- the release-safety checkpoint and the release-process checkpoint distinguish the two flows: a workflow-published release, and a tag-only repository where the release is created by hand against the pushed signed tag
references/supply-chain-security.mdrecords that the SLSA generic generator cannot run under thesha_pinning_requiredruleset:generator_generic_slsa3.ymlatv2.1.0— the latest release, 2025-02-24 — calls four nested actions by tag, and the run is rejected at the first of them. Pinning the generator's ownuses:does not help, the references are inside it. The page namesactions/attest-build-provenanceas what to use there, and says to state the level actually reached rather than the one the workflow is named afterSKILL.md,references/release-process.md,references/immutable-releases.md,README.md,AGENTS.mdand both release commands state the rule as "never without--verify-tag" rather than "never", and name which of the two flows a repository is in as something to establish from its workflowsreferences/release-process.mdsays where the credit goes when the release workflow builds the body fromCHANGELOG.md: into the CHANGELOG entry, because the extracted body carries only the@mentionsthe entries carry. Without them every such release failsrelease-notes-status.shwithMISSING CREDITSuntil someone edits the published body by hand
- a repeated
--verify-tagis decided by its LAST occurrence, as pflag decides it.gh release create v1.2.3 --verify-tag --verify-tag=falsepassed the guard while gh resolved the flag to false and could create the tag; the check stopped at the first enabled occurrence. Both directions are pinned — the same pair reversed is allowed — and a value pflag does not accept counts as off, because gh exits on it and that branch must never be the one letting a command through - an unquoted shell comment is no longer read as arguments.
gh release create v1.2.3 # --verify-tagoffered a flag the shell discards, so the guard allowed the invocation and bash ran the bare create that can mint a lightweight tag;gh release delete v1.2.3 # --helppassed the same way. The comment is now stripped with the quoted spans, before either check, and the notes-only test forgh release edituses the same stripping. Found by CodeRabbit on the pull request that introduced the--verify-tagexemption, reproduced before the fix, and pinned by seven cases including two where a#is part of an argument rather than a comment gh release create --helpandgh release delete -hare no longer blocked. Reading the help runs no release operation, and the blocked command was the one that would have explained--verify-tag- a block message spanning several lines emitted its continuation lines at column 0, which ends the YAML block scalar and leaves the rest as stray text
release-status.shfinds the tag's workflow run again once other runs have happened since the tag. It filtered the 12 newest runs of every workflow on its own side, so Renovate, a merge queue and CI pushed the release run out of that window within hours:netresearch/raybeamv1.2.0 andnetresearch/terraform-provider-adv0.5.3 both reportedworkflow : nonewhile their release runs had succeeded, and a failed release run would have been missed the same way. The lookup now asks GitHub for the tag's runs with--branch, prefers the run whose name says release or publish over CI started by the same push, printsunknownrather thannonewhen the lookup itself fails, and printsin_progress/-for a running run, where gh's emptyconclusionused to leavein_progress/
release-status.sh --watchwaits while the tag's publishing workflow has not completed, prints each state change to stderr, and then gives the normal verdict. Until now the release side had no counterpart topr-status.sh --watch, and every wait on a release run was a hand-written loop
1.0.4 - 2026-09-20
references/recovery-procedures.md: a release workflow that never fired looks like nothing at all — no run, no failure, no red check — so the recovery path now starts by establishing whether the workflow was dispatched, rather than by reading a run that does not exist
1.0.3 - 2026-09-18
references/npm-staged-publishing.md, named in the skill's contents list.npm stage publishputs a version into npm's staging area rather than the registry, where it is not installable until a maintainer approves it with 2FA — so an automated workflow can produce a release without holding a credential that can publish on its own. The page carries the version floor (npm stagefirst shipped in npm 11.15.0, and what decides a CI job is the npm its Node bundles, so Node 22.23.2 at npm 10.9.8 has no such command while Node 24 does), the two registry states a release workflow walks into, and how to capture the stage id. All of it read out ofnpm-stage(1)andlib/commands/stage/as shipped with npm 12.0.2references/recovery-procedures.mddistinguishes the two re-run flavours where the fix lives in a reusable workflow. GitHub resolvesuses: org/repo/.github/workflows/x.yml@mainagain only on a fullgh run rerun;--failedand--jobstay locked to the first attempt's SHA, so the reflex re-run keeps executing the pre-fix copy and fails identically. The section namesgh run view <run-id> --json jobsas where a--jobid is read rather than guessed, and thereferenced_workflowsquery that says which copy a run actually usedreferences/release-process.mddocuments that a dispatch input feedingactions/checkoutmust carry a full 40-character commit SHA. An abbreviated one is an unqualified ref, so checkout fetches it as a branch or tag name: either nothing matches and every job dies at checkout, or something does and the run goes green against a commit nobody asked about. The section carries the post-checkout assertion that catches the silent case
guard-lightweight-tag.pyno longer blocks a force-push whose tag refs are all bare major pointers (v4). A moving major pointer is not a published release — consumers pin it because it moves, and the block's advice to cutvX.Y.Z+1is meaningless for it, while the immutable releases it points at are untouched. Blocking it did not prevent the move, it pushed the author to the tags API, which cannot sign the tag
1.0.2 - 2026-09-17
- The changelog entry for 1.0.1 identified the benchmark case the description was measured on by its identifier. A case name in a skill is readable by an agent working on that case, and the finding does not need it: what the runs showed is that the last file a release touches is the one left behind, which holds wherever a project states its version in more than one place
1.0.1 - 2026-09-17
- The skill description now names every file a version lives in. Measured over eighteen release-preparation runs under a small model: agents prepared the release and updated three of the four places a TYPO3 extension states its version, and eight of nine failures in one round were
CHANGELOG.mdalone, withext_emconf.php,guides.xmland the rendered changelog page all carrying the new version.references/ecosystem-detection.mdalready lists every file with the pattern to change, but a reference is read only by an agent that opens the skill, and these runs mostly did not — the description is what reaches it - The description named "the rendered changelog page" as a role rather than a place. The skill's own reference allows two layouts —
Documentation/Changelog.rstfor a single file,Documentation/Changelog/Index.rstfor a directory — so naming one path would be wrong for extensions using the other. The description now names the directory, which covers both, and the reference keeps the two filenames - Two references and the two remaining script comments cited a repository by name for observations that do not need one: a Packagist block after a retag, a release run where four publication paths failed independently,
grep -qbehind a pipe reporting a found match as a failure, and a comparison across release lines asking a v12 patch for blocks a v13 line introduced. The mechanism is the finding and the name applies to one repository only; the measurements stay (52,989 bytes, first match at byte 2,474, 25 of 40 runs at 141)
1.0.0 - 2026-09-16
0.12.3 - 2026-09-11
- The two PreToolUse guards now share one invocation splitter,
scripts/_invocations.py. It was developed in the tag guard (issue #105, plus the heredoc handling in 0.12.2) while the release guard kept a simpler copy, and the copies drifted apart until one was blind to what the other handled. The tag guard's behaviour is unchanged: its 67 cases pass before and after.
guard-gh-release.pyjudged a whole Bash call as one string and requiredghto sit directly after a separator, so ten dangerous shapes walked past it. A newline is a command separator exactly as;is, but the call was flattened with" ".join(command.split())and the separator set held only[;&|]— sogh release createon its own line was never seen. Nor was one behind a prefix:sudo gh release create,GH_TOKEN=x gh release delete, one inside a loop body or a subshell, andgh api …/releases -X DELETEon its own line. Under immutable releases agh release createburns that tag name permanently, so this was the wrong direction to be wrong in. The guard now splits the call into invocations and judges each on its own, anchored at the start of the invocation — so the words insideecho "never run gh release create v1.2.3"or inside a heredoc body stay words.
0.12.2 - 2026-09-11
- The tag guard read heredoc bodies as script. A heredoc body is data the
command writes, not commands it runs, so a file documenting
git tag -d vX.Y.Zwas judged as a deletion of that tag. The body reachedsplit_invocationsintact, its separators split it into segments, and a quoted example was then checked as a real invocation. Because a denied call runs none of its parts, the file was never written and re-running the same call was denied identically -- so the guard blocked the commit messages, docs and tests that quote its own examples, this repository's included. Bodies are now dropped before splitting; the opener line, the terminator and everything around them are still inspected, including the<<-indented and unquoted-delimiter forms. Dropping only happens where a body provably ends:<<is also an arithmetic left shift, so$(( FLAG << SHIFT ))looks exactly like an opener whose delimiter isSHIFT, and a here-string (<<<) looks like one whose delimiter is its word. Neither is ever terminated, and stripping on sight would have swallowed the rest of the command -- a real tag deletion on a later line included.
0.12.1 - 2026-09-09
- The tag guard judged a whole command by its first
git tagoccurrence. It collapsed newlines into spaces and captured to the end of the command, so one match decided the verdict for everything after it — in both directions. A read-only listing was blocked when a later, unrelated line held a version token, and once that first match returned early on-l, a real tag creation, tag deletion or tag force-push further along the same command was never examined (#105). - Splitting the command applied separators wherever they appeared and ignored
grouping entirely. An invocation inside a subshell, a brace group or a command
substitution was invisible, while a separator inside a quoted argument split
one invocation into two and reported a commit message as a lightweight tag.
A
\"inside a double-quoted argument was read as the closing quote, which hid the invocation that followed (#112). - The guard blocked forms that create no lightweight tag: the read-only
inspection flags git treats as implying
--list(-n,--contains,--points-at,--merged,--sort,--format,--column,--ignore-case), and-m/-F, which imply-a. It allowed a quoted tag name, which creates exactly the tag the bare form creates.git tag --delete vX.Y.Zwas reported as a lightweight tag rather than as a deletion.
- The shipped TER callers (
templates/release-typo3.yml,templates/ter-publish.ymland the pattern inreferences/ter-republish.md) show how to passexclude-from-packaging, commented out with the reason beside it: the shared workflow fails the job when the path does not exist, so an active line would break every adopter without that exact file (#104). references/ter-republish.mdno longer names three specific extension repositories where a generic phrase carries the same meaning (#92).- The
netresearch/skill-repo-skillpre-commit hook moves to v2.0.1.