Releasing
playbooks/releasingChecked by automation only
still a draft
Written 2026-10-29 by ktl-librarian (automation). Last checked 2026-10-29 by ktl-librarian (automation).
The full record
- Checks
- 2026-10-29 by
ktl-librarian(automation)
- 2026-10-29 by
- Sources
.github/workflows/publish.ymldocs/releasing.md.github/workflows/semantic-release.yml.github/scripts/changelog-release.mjs.releaserc.json.github/workflows/knowledge-release.yamlscripts/sync-sidecar.shCHANGELOG.mdhttps://github.com/semantic-release/commit-analyzer/blob/v13.0.1/README.mdhttps://github.com/semantic-release/commit-analyzer/blob/v13.0.1/lib/default-release-rules.jshttps://github.com/conventional-changelog/conventional-changelog/blob/conventional-changelog-conventionalcommits-v8.0.0/packages/conventional-changelog-conventionalcommits/src/parser.js
- References
- Depends on
- Pointed to by
- Contributing (references)
- The docent in Microsoft 365 Copilot (references)
- Versioning policy (related to)
- Graph
- Open this concept in the knowledge graph
- IRI
https://w3id.org/ktl/knowledge/playbooks/releasing- Licence
- CC BY 4.0
- Files
- MarkdownJSON-LDSource on GitHubHistory on GitHub
Overview
Section titled “Overview”A person no longer hand-picks the version, but two tools never race to tag it. semantic-release.yml’s release job runs on every push to main, behind the release GitHub Environment. @semantic-release/commit-analyzer computes the next version from Conventional Commits since the last tag, using .releaserc.json’s releaseRules. Those rules add one type, security:, as a patch. A commit they do not match falls to the analyzer’s default rules, where fix:, perf: and a revert give a patch, feat: a minor release, and a BREAKING CHANGE: note a major one. Since 0.57.0,1 the analyzer reads each commit with the Conventional Commits preset, which .releaserc.json names and semantic-release.yml pins as conventional-changelog-conventionalcommits@8.0.0 beside @semantic-release/commit-analyzer@13.0.1. That preset’s header pattern allows a ! after the type, so a feat!: commit gives a major release, as docs/releasing.md says. The plan job counts such a commit as one that would release.
Semantic-release always runs with --dry-run, so it never writes, commits, tags, or publishes anything here. @semantic-release/exec calls .github/scripts/changelog-release.mjs check with the computed version, which refuses to proceed if CHANGELOG.md’s ## [Unreleased] section is empty, and notes, which supplies the release notes.
Since 0.38.0 an empty [Unreleased] passes check when that version is already promoted and waits for publish.yml. A push with nothing new, such as a docs merge after a promotion, still finds the releasing commit since the last tag and asks for that version. notes then writes no next_version, so nothing is promoted twice. A failing check also writes its reason as a GitHub annotation. A plain shell step afterward:
- reads the version
--dry-runcomputed; - promotes that section to a dated heading itself;
- moves the librarian template’s
TRUST_LADDER_SKILLS_REFto the newest tag a host can clone; - commits both directly as
chore(release): <version> - changelog promoted [skip ci].
The pin is two values, so the same step moves TRUST_LADDER_SKILLS_SHA to the commit that tag names: the install step refuses a tag that names any other commit. Only the template’s pin moves. GITHUB_TOKEN may not push a change under .github/workflows/, so this repository’s own copy keeps its old pin, which check 11 ignores (2026-09-24). gh skill publish stays this repository’s only tag creator, for the reasons below.
Two qualifying merges to main between one publish.yml run and the next compute the same next version twice. The next version is computed from the last tag, and gh skill publish is a separate, later, human-triggered step. promote now checks whether the top released heading’s version is still untagged. If so, it folds the new entries into it by ### subsection instead of inserting a second heading for the same version. That was the bug that wrote two ## [0.19.0] - 2026-09-17 headings, orphaning the second merge’s entries above an empty [Unreleased]. Check 14 in playbooks/repository-validation.md proves the fold and would have caught the original duplication. Since 0.42.0 the fold leaves out an entry the pending section already holds, because a pull request stacked on another still carries the other’s entries under [Unreleased], and check 14 proves that each entry is listed once.
The plan job also calls changelog-release.mjs check as its own step, not only inside --dry-run. Semantic-release detects a pull-request event and skips every plugin lifecycle hook, including @semantic-release/exec’s. So the check the paragraph before describes never ran on a pull request until this fix, and an empty [Unreleased] would have merged silently. That step reads the pull request’s own commits first, from the merge-base of the base branch and the head commit (2026-10-21). HEAD is GitHub’s trial merge with that day’s main, so on a branch that main has moved past it would read main’s commits too. It runs check only when one of them would release (the types .releaserc.json acts on). So a chore: or docs: pull request, a Dependabot action bump among them, is not failed for notes it was never going to release.
When the commits would release, a second step requires the pull request title to carry a releasing type, because a squash merge takes its subject from the title. GitHub’s default title has none: pull request #46 merged that way and released nothing, its notes left waiting in [Unreleased]. The trigger names edited alongside the default pull-request types, because retitling is how that check is cleared and the default types never fire on a title change. Without it, the remedy the error message asks for could not clear the check.
Releases are still never tag-triggered. gh skill publish creates the tag and the GitHub release itself, so a tag-push trigger would race the tag the command is about to create. workflow_dispatch keeps the timing an explicit human decision. The release environment adds a maintainer-approval gate in front of the contents: write scope, the same Environment that semantic-release.yml’s write-scoped job now waits behind too.
Each publish.yml run validates:
- that the input is
vMAJOR.MINOR.PATCH; - that the tag does not already exist;
- that the bare version matches the top released heading in CHANGELOG.md, skipping
## [Unreleased], which catches a typed version nobody wrote release notes for.
It then sets up uv and re-runs the repository contract, the sidecar scripts’ tests under tests/ (run with pytest) and gh skill publish --dry-run, and only then publishes. The version is typed with the v (v0.16.0). The changelog heading never carries one, and the cross-check strips it before comparing.
Since 2026-09-24 it then dispatches knowledge-release.yaml for the new tag. That workflow attaches the bundle to the release as knowledge-vX.Y.Z-knowledge-trust-ladder.zip when the bundle changed since the last release that carries one. The zip is named after the repository, or after the name its KNOWLEDGE_RELEASE_NAME variable sets. Beside it (since 2026-09-24) it attaches the docent as a Microsoft 365 Copilot skill, ktl-docent-m365-vX.Y.Z-knowledge-trust-ladder.zip. It attaches one zip per instructions file under .lokf/m365/, each with its own checksum and attested alongside the bundle zip. If the builder is missing or refuses the bundle, the release carries the bundle zip alone.
Since 0.46.0, when the host’s .lokf/justfile names a domain schema on its schema := line, the workflow also attaches that file as schema-<tag>-<name>.yaml, with its own .sha256 and attestation. A federation that admits the bundle can then validate it on its own terms. Since 0.55.0 the line may name any file in the repository, such as a linkml-sidecar schema, but never one outside it, and the released copy imports LOKF core as lokf. Since 0.59.0 a release whose only change is the schema packs again, and any other local file the schema imports is merged into the released copy, so a composite of two lokf adapt copies is released as one file. The merge stops the release on a name two files define differently.
The dispatch is needed because GitHub starts no workflow from a release made with GITHUB_TOKEN, so that workflow’s own release trigger never fires here. If the dispatch fails, the job fails and names the manual run to make.
Since 0.47.0 publish.yml then asks the hosts named in the KNOWLEDGE_SYNC_HOSTS repository variable, as owner/repo separated by spaces, to run their knowledge-sync.yaml for the new tag. The dispatch needs the KNOWLEDGE_SYNC_DISPATCH_TOKEN secret, a token with Actions write on each host. Unset, or set to false, the variable asks nobody, and each host syncs on its own schedule. A host that cannot be asked is reported, and the others go ahead.
This release-process detail moved out of CONTRIBUTING.md on 2026-09-14 to docs/releasing.md, which states it for the three KTL repositories in one place. All six skills are released together under one tag, so a consumer can pin them to a single release. The release Environment’s required reviewers are configured once, by hand, in each repository’s settings, or a qualifying merge is released unattended. Changes reach main by pull request, but no ruleset enforces it, and “Require signed commits” as a branch rule is off and must stay off.
The sibling repositories (the two Obsidian plugins) carry byte-identical copies of the sidecar templates and their own skills pin. Only the first line of .lokf/justfile, which names the host, and its schema := line are a sibling’s own.
Since 0.47.0 each sibling keeps itself current. Its knowledge-sync.yaml reads the pin and this repository’s newest release each week. When the pin is behind, it runs the sibling’s own .lokf/scripts/knowledge-sync.sh, which reads what to copy from the release’s templates/knowledge-sync.manifest, and opens one pull request with the diff: the copies, the pin, and the changelog entry under ## [Unreleased] and ### Security. A later week’s run refreshes that one pull request to the newest release until it is merged. Since 0.53.0 the branch the sync pushes carries no workflow a release wrote (CHANGELOG.md). publish keeps every workflow but for the pin, and holds the release’s workflow changes for a person, or for a reviewer of the Environment that KNOWLEDGE_SYNC_REVIEW_ENVIRONMENT names. The header of knowledge-sync.sh says what a sync copies, adds and refuses.
A new template goes in the manifest and in ktl-sidecar’s Step 5 table, and nowhere else, because the preflight and contract check 11 read the manifest.
To open the pull requests on the day of a release, a maintainer runs scripts/sync-sidecar.sh --pr <tag> <sibling>... from this repository once the tag is on origin. The script:
- refuses anything but a release tag that is on origin and carries
skills/ktl-librarian/SKILL.md; - runs each sibling’s own copy of
knowledge-sync.sh, or the tag’s copy in a sibling that has none, on a branch of its own,sync-<tag>, from origin’s default branch; - skips a sibling with uncommitted changes;
- without
--pr,--pushor--commit, leaves the sync uncommitted on the branch with the commit command printed, so the diff is the review.
Once the pull requests are merged, scripts/sync-sidecar.sh --finish <tag> <sibling>... puts each sibling back on its default branch, brings it up to date and removes the branch.
The pin and the copies move together because the skill a tag installs is written against the wrapper, gate and preflight that tag includes. Once a sibling turns on its scheduled librarian, the pin also decides which instructions run unattended. A release that touched neither the templates nor the librarian skill needs no sync, and the workflow and the script both say so. The template’s pin is never bumped by hand, which would cut a pointless release. A tag that is not on origin is refused, because a sibling’s scheduled librarian clones from there and would fail to install.
Both checks read the workflow_dispatch version input through an env: variable rather than interpolating ${{ inputs.version }} straight into the shell script. That hardening change (2026-09-12) closes a script-injection vector without changing what the checks verify.
Footnotes
Section titled “Footnotes”-
CHANGELOG.md, 0.57.0, Fixed ↩