Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ If guidance conflicts, follow the order above.
- Shared repo policy/settings (`common-settings.yaml`, `.github/settings.yml`)
- Development standards and quality gates (TypeScript/ESLint/Prettier)
- Control-plane TypeScript (`scripts/*.ts`, executed with Node 24 native TS; no build step)
- Package release (`.github/workflows/release-wiki-write-core.yaml`, `packages/wiki-write-core/.releaserc.yaml`, `scripts/wiki-write-core-release.ts`): a merge to `main` that changes `packages/wiki-write-core/` publishes `@fro.bot/wiki-write-core` to npm via semantic-release; the release tools install into a scratch prefix, never `package.json` or the lockfile
- Metadata state (`metadata/*.yaml`, written programmatically to the `data` branch)
- Knowledge wiki (`knowledge/{schema,index,log}.md` + `knowledge/wiki/`, Karpathy-style LLM-generated)
- Documented solutions (`docs/solutions/`, categorized learnings and fixes with YAML frontmatter for searchability)
Expand Down
17 changes: 17 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
matchPackageNames: ['!python', '!typescript'],
enabled: false,
},
{
// These tools are pinned in release-wiki-write-core.yaml, not in package.json, so the patch-update
// default above would leave them unmaintained.
description: 'Re-enable patch updates for the pinned release tools of @fro.bot/wiki-write-core.',
matchUpdateTypes: ['patch'],
matchPackageNames: [
'@semantic-release/commit-analyzer',
'@semantic-release/exec',
'@semantic-release/github',
'@semantic-release/npm',
'@semantic-release/release-notes-generator',
'conventional-changelog-conventionalcommits',
'conventional-changelog-writer',
'semantic-release',
],
enabled: true,
},
{
description: 'Do not group TypeScript and Python with other updates',
matchPackageNames: ['python', 'typescript'],
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ jobs:
RENOVATE_PREFIX: ${{ runner.temp }}/renovate
run: pnpm exec vitest run scripts/check-renovate-config.test.ts scripts/check-renovate-ranges.test.ts scripts/renovate-runtime.test.ts

- name: 📦 Install Release Tools
# The semantic-release tool set pinned in release-wiki-write-core.yaml goes into a scratch prefix
# outside the checkout, with lifecycle scripts off. Nothing is added to package.json or pnpm-lock.yaml.
env:
WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release
run: node scripts/wiki-write-core-release.ts install

- name: 🧪 Test Release Decision Against Pinned Release Tools
# `pnpm test` skips the tests that need the real plugins; run them here, against the install
# the previous step left in WIKI_WRITE_CORE_RELEASE_PREFIX. They fail if that prefix is empty.
env:
WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release
run: pnpm exec vitest run scripts/wiki-write-core-release.test.ts

check-wiki-authority:
name: Check Wiki Authority
# Only pull_request events carry a PR author to evaluate against the guard.
Expand Down
272 changes: 272 additions & 0 deletions .github/workflows/release-wiki-write-core.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
# Releases @fro.bot/wiki-write-core to npm on every merge to main that changes the package.
#
# Three jobs, split by what they may hold:
# decide (contents: read) runs repository code: picks the commits that touch the package and computes
# the release type, version and notes. Holds no credential.
# build (contents: read) proves the committed packages/wiki-write-core/dist equals a fresh build.
# publish (contents: write, id-token: write, environment: release) runs NO repository code: it validates
# decide's outputs against fixed patterns, writes a pure-data semantic-release
# config from the template below, and publishes the committed dist from its own
# checkout.
#
# The npm trusted publisher is bound to this workflow's filename. Rename it only together with that binding.
name: Release Wiki Write Core

on:
push:
branches: [main]
workflow_dispatch:

# Least-privilege default: no token capabilities at the workflow level. Each job grants only what it needs.
permissions: {}

# Serialize releases; never cancel one mid-flight.
concurrency:
group: release-wiki-write-core
cancel-in-progress: false

env:
# The one declaration of the release tools. scripts/wiki-write-core-release.ts reads these from this file
# for the scratch install, and the inherited bfra-me regex manager for `*_VERSION: x # renovate: ...` in
# workflow files keeps them current. They are not repo dependencies: semantic-release's tree carries
# `braces` with no patched version, which `check:override-floors` fails on. `publish` installs the first
# four; `decide` installs all eight.
SEMANTIC_RELEASE_VERSION: 25.0.9 # renovate: datasource=npm depName=semantic-release
SEMANTIC_RELEASE_EXEC_VERSION: 7.1.0 # renovate: datasource=npm depName=@semantic-release/exec
SEMANTIC_RELEASE_NPM_VERSION: 13.2.0 # renovate: datasource=npm depName=@semantic-release/npm
SEMANTIC_RELEASE_GITHUB_VERSION: 12.0.10 # renovate: datasource=npm depName=@semantic-release/github
SEMANTIC_RELEASE_COMMIT_ANALYZER_VERSION: 13.0.1 # renovate: datasource=npm depName=@semantic-release/commit-analyzer
SEMANTIC_RELEASE_NOTES_GENERATOR_VERSION: 14.1.1 # renovate: datasource=npm depName=@semantic-release/release-notes-generator
CONVENTIONAL_CHANGELOG_CONVENTIONALCOMMITS_VERSION: 10.4.1 # renovate: datasource=npm depName=conventional-changelog-conventionalcommits
CONVENTIONAL_CHANGELOG_WRITER_VERSION: 9.3.0 # renovate: datasource=npm depName=conventional-changelog-writer

defaults:
run:
shell: bash -Eeuo pipefail {0}

jobs:
# ---------------------------------------------------------------------------
# Decide job: credential-free. Runs repository code, so it holds read scopes only.
# ---------------------------------------------------------------------------
decide:
name: Decide package release
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
type: ${{ steps.decision.outputs.type }}
version: ${{ steps.decision.outputs.version }}
base-tag: ${{ steps.decision.outputs.base-tag }}
steps:
- name: ⤵ Checkout Branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false

- name: 📦 Setup
uses: ./.github/actions/setup

- name: 📦 Install Release Tools
env:
WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release
run: node scripts/wiki-write-core-release.ts install

- name: 🧪 Test Release Decision Against Pinned Release Tools
env:
WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release
run: pnpm exec vitest run scripts/wiki-write-core-release.test.ts

- name: 🔎 Decide release
id: decision
env:
WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release
run: node scripts/wiki-write-core-release.ts decide --notes "$RUNNER_TEMP/release-notes/notes.md"

- name: 📤 Upload release notes
if: steps.decision.outputs.type != 'none'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-notes
path: ${{ runner.temp }}/release-notes/notes.md
if-no-files-found: error
retention-days: 7

# ---------------------------------------------------------------------------
# Build job: credential-free. Proves the committed dist is what a fresh build produces; nothing it
# builds is published.
# ---------------------------------------------------------------------------
build:
name: Verify package build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: ⤵ Checkout Branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: 📦 Setup
uses: ./.github/actions/setup

- name: ✅ Verify committed package build
run: pnpm check:wiki-write-core-dist

# ---------------------------------------------------------------------------
# Publish job: the only job with write scopes and the release environment. It runs no repository script and
# executes no command taken from another job's output.
# ---------------------------------------------------------------------------
publish:
name: Publish package
needs: [decide, build]
if: >-
github.ref == 'refs/heads/main' &&
needs.decide.result == 'success' &&
needs.build.result == 'success' &&
contains(fromJSON('["major","minor","patch"]'), needs.decide.outputs.type)
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: release
permissions:
contents: write
id-token: write
steps:
- name: ⤵ Checkout Branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false

# No registry-url: it would write an .npmrc that forces token auth and defeat npm trusted publishing
# (OIDC).
- name: 📦 Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24

- name: 🔒 Require npm >= 11.5.1
id: npm-gate
run: |
version=$(npm --version)
oldest=$(printf '%s\n' 11.5.1 "$version" | sort -V | head -n 1)
if [ "$oldest" != "11.5.1" ]; then
echo "::error::npm $version is older than 11.5.1, which trusted publishing requires"
exit 1
fi

- name: 📥 Install release tools
id: install
run: |
tools="$RUNNER_TEMP/wiki-write-core-release-tools"
mkdir -p "$tools"
npm install --prefix "$tools" --no-save --no-package-lock --ignore-scripts --no-audit --no-fund \
"semantic-release@${SEMANTIC_RELEASE_VERSION}" \
"@semantic-release/exec@${SEMANTIC_RELEASE_EXEC_VERSION}" \
"@semantic-release/npm@${SEMANTIC_RELEASE_NPM_VERSION}" \
"@semantic-release/github@${SEMANTIC_RELEASE_GITHUB_VERSION}"

# Every value below came from `decide`, which runs repository code. None is executed or interpolated into
# a command: each is matched against a fixed pattern, the version is recomputed from the base tag and type,
# and git confirms the package really changed since the base tag. The values are never echoed.
- name: 🛡 Validate release decision
id: validate
env:
DECISION_TYPE: ${{ needs.decide.outputs.type }}
DECISION_VERSION: ${{ needs.decide.outputs.version }}
DECISION_BASE_TAG: ${{ needs.decide.outputs.base-tag }}
run: |
fail() { echo "::error::$1"; exit 1; }
semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'

[[ $DECISION_TYPE =~ ^(major|minor|patch)$ ]] || fail "the decided release type is not major, minor or patch"
[[ $DECISION_VERSION =~ $semver_re ]] || fail "the decided version is not x.y.z"
[[ $DECISION_BASE_TAG == wiki-write-core-v* ]] || fail "the decided base tag is not a wiki-write-core-v tag"
base_version=${DECISION_BASE_TAG#wiki-write-core-v}
[[ $base_version =~ $semver_re ]] || fail "the decided base tag is not a wiki-write-core-v tag"

IFS=. read -r major minor patch <<< "$base_version"
case "$DECISION_TYPE" in
major) expected="$((major + 1)).0.0" ;;
minor) expected="${major}.$((minor + 1)).0" ;;
patch) expected="${major}.${minor}.$((patch + 1))" ;;
esac
[ "$DECISION_VERSION" = "$expected" ] || fail "the decided version is not the base tag version incremented by the decided type"
if [ "$DECISION_TYPE" = major ] && [ "$major" -eq 0 ]; then
fail "a 0.x package does not release a major version"
fi

git rev-parse --verify --quiet "refs/tags/${DECISION_BASE_TAG}^{commit}" > /dev/null \
|| fail "the decided base tag is not present in this checkout"
git merge-base --is-ancestor "refs/tags/${DECISION_BASE_TAG}" HEAD \
|| fail "the decided base tag is not an ancestor of the checked-out commit"
count=$(git rev-list --count "refs/tags/${DECISION_BASE_TAG}..HEAD" -- packages/wiki-write-core/)
if [ "$count" -lt 1 ]; then
fail "no commit since the base tag changes packages/wiki-write-core/"
fi

- name: 📥 Download release notes
id: notes
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-notes
path: ${{ runner.temp }}/release-notes

# The package's own .releaserc.yaml is deleted so the config below is the only one semantic-release can
# find. It is pure data: the notes file is only ever `cat`, and the version assertion compares strings.
- name: 🧾 Stage release config
id: stage
env:
DECISION_TYPE: ${{ needs.decide.outputs.type }}
DECISION_VERSION: ${{ needs.decide.outputs.version }}
run: |
notes_dir="$RUNNER_TEMP/release-notes"
notes="$notes_dir/notes.md"
if [ "$(find "$notes_dir" -mindepth 1 | wc -l)" -ne 1 ] || [ ! -f "$notes" ] || [ -L "$notes" ] || [ ! -s "$notes" ]; then
echo "::error::the release notes artifact must contain exactly one non-empty regular file, notes.md"
exit 1
fi

rm -f packages/wiki-write-core/.releaserc.yaml
jq -n \
--arg type "$DECISION_TYPE" \
--arg version "$DECISION_VERSION" \
--arg notes "$notes" \
--arg repositoryUrl "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" \
'{
branches: ["main"],
tagFormat: "wiki-write-core-v${version}",
repositoryUrl: $repositoryUrl,
plugins: [
["@semantic-release/exec", {
analyzeCommitsCmd: ("echo " + $type),
verifyReleaseCmd: ("test \"${nextRelease.version}\" = \"" + $version + "\""),
generateNotesCmd: ("cat \"" + $notes + "\"")
}],
["@semantic-release/npm", {pkgRoot: "packages/wiki-write-core"}],
["@semantic-release/github", {
releasedLabels: false,
successComment: false,
failComment: false,
failTitle: false
}]
]
}' > .releaserc.json

- name: 🚀 Release
id: release
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: 'true'
npm_config_ignore_scripts: 'true'
run: '"$RUNNER_TEMP/wiki-write-core-release-tools/node_modules/.bin/semantic-release" --ci'
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ This repository provides shared configurations and automation for the Fro Bot ec
├── .github/ # GitHub-specific configurations
│ ├── actions/setup/ # Composite bootstrap action
│ ├── hooks/ # Copilot governance hooks
│ ├── workflows/ # 31 GitHub Actions workflows (see Automation)
│ ├── workflows/ # 32 GitHub Actions workflows (see Automation)
│ ├── copilot-instructions.md # Canonical AI-assistant guidance
│ ├── renovate.json5 # Dependency management config
│ └── settings.yml # Repository settings via Probot
Expand Down Expand Up @@ -235,6 +235,7 @@ Fro Bot control plane:
| **Cross-Repo Dispatch** | Decompose, dispatch, and track cross-repo goal items via worker receipts | Issue labeled/reopened, every 6 hours, dispatch |
| **Publish Wiki** | Build the Quartz site from the wiki and deploy it to GitHub Pages | Push to main touching `knowledge/wiki/**`, `knowledge/index.md`, `knowledge/schema.md`, or `quartz-site/**`; dispatch |
| **Unpublish Wiki (emergency takedown)** | Replace the live wiki site with a static "unavailable" page | Manual dispatch |
| **Release Wiki Write Core** | Publish `@fro.bot/wiki-write-core` to npm with provenance when a merge changes `packages/wiki-write-core/`: a credential-free job decides the release and a job in the `release` environment publishes the committed `dist` | Push to main; dispatch |

Repository management:

Expand Down
56 changes: 56 additions & 0 deletions packages/wiki-write-core/.releaserc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Release policy for @fro.bot/wiki-write-core. Read only by scripts/wiki-write-core-release.ts in the
# credential-free `decide` job; the `publish` job deletes this file and runs semantic-release from a
# gated config written by .github/workflows/release-wiki-write-core.yaml.
branches:
- main

tagFormat: 'wiki-write-core-v${version}'

analyzeCommits:
releaseRules:
# The package stays 0.x: a breaking change is a minor bump, never major. The scoped suppression
# rules below still win when a breaking commit also matches one of them (`build(dev)!:` releases
# nothing).
- breaking: true
release: minor
- type: build
release: patch
- type: build
scope: dev
release: false
- type: docs
release: patch
scope: readme
- type: skip
release: false

preset: conventionalcommits

presetConfig:
types:
- type: feat
section: Features
- type: features
section: Features
- type: build
section: Build System
- type: fix
section: Bug Fixes
- type: docs
section: Documentation
- type: test
section: Tests
- type: ci
section: Continuous Integration
- type: style
section: Styles
- type: refactor
section: Code Refactoring
- type: perf
section: Performance Improvements
- type: revert
section: Reverts
- type: chore
section: Miscellaneous Chores
- type: skip
effect: hidden
Loading
Loading