Skip to content
Open
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
21 changes: 21 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "traxsharp",
"owner": {
"name": "TraxSharp",
"url": "https://github.com/TraxSharp"
},
"metadata": {
"description": "Agent skills for building apps on Trax .NET"
},
"plugins": [
{
"name": "trax",
"description": "Agent skills for building apps on Trax .NET",
"source": {
"source": "github",
"repo": "TraxSharp/Trax",
"ref": "skills"
}
}
]
}
2 changes: 2 additions & 0 deletions .claude/skills/recording-decisions/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
---
name: recording-decisions
description: Record an architecture decision as an ADR in the Trax repository. Use when a change makes a decision that is hard to reverse, surprising without context, and the result of a real trade-off, or when editing, superseding or reviewing an existing ADR.
metadata:
internal: true
---

# Recording decisions
Expand Down
87 changes: 87 additions & 0 deletions .github/scripts/publish-skills.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Commits a staged skills tree on top of the live branch, to a staging branch, for a maintainer to
# approve (.github/workflows/skills.yml; Trax.Docs/adr/0056).
#
# publish-skills.sh <staged dir> <owner/repo> <live branch> <staging branch> <commit headline>
#
# The staging branch is reset to the live branch's head and the tree is committed on it through
# GraphQL createCommitOnBranch, so GitHub signs the commit and the repository's verified-signatures
# rule holds without a key in this job. The tree replaces what is there: a file the release no
# longer stages is deleted. The commit's parent is the live head, so moving the live branch to it
# is a fast-forward. Prints what changes, and writes `commit=<sha>` to $GITHUB_OUTPUT: the new
# commit, or the live head when the tree is already live.
#
# GH_TOKEN must be able to write the staging branch. It cannot write the live branch, whose ruleset
# refuses it; the publish job does that with a deploy key once approved.
set -euo pipefail

dir=$1
repo=$2
live=$3
staging=$4
headline=$5
work=$(mktemp -d)
out=${GITHUB_OUTPUT:-/dev/null}
summary=${GITHUB_STEP_SUMMARY:-/dev/null}

if ! head=$(gh api "repos/$repo/git/ref/heads/$live" --jq .object.sha 2>/dev/null); then
echo "::error::$repo has no $live branch. Create it once with a signed commit (see the ADR) and re-run."
exit 1
fi
tree=$(gh api "repos/$repo/git/commits/$head" --jq .tree.sha)

gh api "repos/$repo/git/trees/$tree?recursive=1" \
--jq '.tree[] | select(.type == "blob") | .path + "\t" + .sha' | sort > "$work/existing"
(cd "$dir" && find . -type f -printf '%P\n' | sort | while IFS= read -r f; do
printf '%s\t%s\n' "$f" "$(git hash-object -- "$f")"
done) > "$work/staged"

if cmp -s "$work/existing" "$work/staged"; then
echo "$repo $live already holds this tree; publishing tags $head."
echo "commit=$head" >> "$out"
exit 0
fi

# What the approver is asked to accept, by path.
tick='`'
{
echo "### Changes to $tick$live$tick"
echo
comm -13 "$work/existing" "$work/staged" | cut -f1 | while IFS= read -r p; do echo "- added or changed: $tick$p$tick"; done
comm -23 <(cut -f1 "$work/existing") <(cut -f1 "$work/staged") | while IFS= read -r p; do echo "- removed: $tick$p$tick"; done
} | tee -a "$summary"

# Start the staging branch at the live head, creating it the first time.
if gh api "repos/$repo/git/ref/heads/$staging" --silent 2>/dev/null; then
gh api -X PATCH "repos/$repo/git/refs/heads/$staging" -f sha="$head" -F force=true --silent
else
gh api "repos/$repo/git/refs" -f ref="refs/heads/$staging" -f sha="$head" --silent
fi

cut -f1 "$work/staged" | while IFS= read -r f; do
jq -n --arg path "$f" --rawfile contents <(base64 -w0 -- "$dir/$f") '{path: $path, contents: $contents}'
done | jq -s . > "$work/additions.json"
comm -23 <(cut -f1 "$work/existing") <(cut -f1 "$work/staged") \
| jq -R '{path: .}' | jq -s . > "$work/deletions.json"

jq -n \
--arg repo "$repo" --arg branch "$staging" --arg head "$head" --arg headline "$headline" \
--slurpfile additions "$work/additions.json" --slurpfile deletions "$work/deletions.json" \
'{
query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }",
variables: { input: {
branch: { repositoryNameWithOwner: $repo, branchName: $branch },
expectedHeadOid: $head,
message: { headline: $headline },
fileChanges: { additions: $additions[0], deletions: $deletions[0] }
} }
}' > "$work/body.json"

commit=$(gh api graphql --input "$work/body.json" --jq .data.createCommitOnBranch.commit.oid)
if [ -z "$commit" ] || [ "$commit" = null ]; then
echo "::error::createCommitOnBranch returned no commit for $repo $staging."
exit 1
fi
echo "Committed $commit to $repo $staging, on top of $live ($head)."
echo "Compare: https://github.com/$repo/compare/$head...$commit" | tee -a "$summary"
echo "commit=$commit" >> "$out"
208 changes: 208 additions & 0 deletions .github/workflows/skills.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
name: Skills

# Publishes the agent skills (Trax.Docs/skills/) to the `skills` branch, which Claude Code's `trax`
# plugin and `npx skills add .../tree/skills` install from
# (Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md).
#
# Dispatch it by hand after a release, with the tag the release cut, the way website.yml publishes
# the site. Three jobs, each holding only what it needs:
#
# stage builds Trax.Skills.Publish from the tag and stages the tree: the source marker removed,
# the release's version stamped in. Read-only.
# prepare commits that tree to `skills-next`, on top of `skills`, through createCommitOnBranch,
# so GitHub signs it. Its token can write contents; the commit is a preview, not live.
# publish waits for approval on the `skills-deploy` environment, then fast-forwards `skills` to
# exactly the commit prepare made and tags it skills-v<version>, with the deploy key.
#
# `skills` and `skills-v*` are locked by rulesets whose only bypass is deploy keys, and the key is a
# `skills-deploy` environment secret, so an approved run of this workflow is the only way to change
# what consumers install. Before approving, read the diff prepare prints between `skills` and the
# commit it made.
on:
workflow_dispatch:
inputs:
ref:
description: 'The release tag to publish, e.g. v1.70.0.'
type: string
required: true

concurrency:
group: skills
cancel-in-progress: false

permissions: {}

jobs:
stage:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
outputs:
version: ${{ steps.ref.outputs.version }}
digests: ${{ steps.digests.outputs.digests }}
steps:
- name: Harden runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: block
disable-telemetry: true
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
aka.ms:443
builds.dotnet.microsoft.com:443
ci.dot.net:443
dot.net:443
api.nuget.org:443
- name: Only a release tag
id: ref
env:
REF: ${{ inputs.ref }}
run: |
# Skills describe released packages, so main is not accepted, unlike the site.
if ! [[ "$REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::'$REF' is not a release tag (vX.Y.Z)."
exit 1
fi
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
- name: Checkout the tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/tags/${{ inputs.ref }}
persist-credentials: false
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- name: Stage the skills
env:
VERSION: ${{ steps.ref.outputs.version }}
run: |
dotnet restore Trax.Docs/tools/Trax.Skills.Publish --locked-mode
dotnet run --project Trax.Docs/tools/Trax.Skills.Publish --configuration Release --no-restore -- \
stage --skills Trax.Docs/skills --version "$VERSION" --license LICENSE --out skills
- name: Record digests
# Handed to prepare as a job output rather than inside the artifact, so prepare can tell
# whether what it downloaded is what this job staged.
id: digests
run: echo "digests=$(cd skills && find . -type f -printf '%P\n' | LC_ALL=C sort | xargs -d '\n' sha256sum -- | base64 -w0)" >> "$GITHUB_OUTPUT"
- name: Upload skills artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: skills
path: skills/
# .claude-plugin/ holds the plugin manifest.
include-hidden-files: true
if-no-files-found: error
retention-days: 1

prepare:
needs: stage
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # skills-next only; skills itself refuses this token
outputs:
commit: ${{ steps.commit.outputs.commit }}
steps:
- name: Harden runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: block
disable-telemetry: true
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
- name: Checkout the scripts
# For the publish script only; nothing in it is built or run.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/tags/${{ inputs.ref }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Download skills artifact
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: skills
path: skills
- name: Verify the skills are the ones stage staged
env:
DIGESTS: ${{ needs.stage.outputs.digests }}
run: |
echo "$DIGESTS" | base64 -d > "$RUNNER_TEMP/expected-skills.sha256"
if [ "$(cd skills && find . -type f -printf '%P\n' | LC_ALL=C sort)" != "$(awk '{print $2}' "$RUNNER_TEMP/expected-skills.sha256")" ]; then
echo "::error::The downloaded skills are not the set the stage job staged."
exit 1
fi
(cd skills && sha256sum --check --strict "$RUNNER_TEMP/expected-skills.sha256")
- name: Commit to skills-next
id: commit
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.stage.outputs.version }}
run: .github/scripts/publish-skills.sh skills "$GITHUB_REPOSITORY" skills skills-next "Trax $VERSION"

publish:
needs: [stage, prepare]
runs-on: ubuntu-latest
timeout-minutes: 5
# Waits here for a maintainer's approval.
environment: skills-deploy
# The push authenticates with the deploy key, not the workflow token.
permissions: {}
steps:
- name: Harden runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: block
disable-telemetry: true
allowed-endpoints: >
github.com:443
api.github.com:443
github.com:22
- name: Move the skills branch and tag it
env:
SHA: ${{ needs.prepare.outputs.commit }}
VERSION: ${{ needs.stage.outputs.version }}
DEPLOY_KEY: ${{ secrets.SKILLS_DEPLOY_KEY }}
# github.com's published ed25519 host key (https://api.github.com/meta), pinned so the key is
# only ever offered to GitHub.
GITHUB_HOST_KEY: github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
run: |
if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::prepare handed on '$SHA', not a commit."
exit 1
fi
if [ -z "$DEPLOY_KEY" ]; then
echo "::error::SKILLS_DEPLOY_KEY is not set on the skills-deploy environment."
exit 1
fi

umask 077
printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key"
printf '%s\n' "$GITHUB_HOST_KEY" > "$RUNNER_TEMP/known_hosts"
export GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/known_hosts"
remote="git@github.com:$GITHUB_REPOSITORY.git"
tag="skills-v$VERSION"

git init -q "$RUNNER_TEMP/skills"
cd "$RUNNER_TEMP/skills"
git fetch -q --depth=2 "$remote" "$SHA"

# A tag is never moved: one that exists must already name this commit.
existing=$(git ls-remote "$remote" "refs/tags/$tag" | cut -f1)
if [ -n "$existing" ] && [ "$existing" != "$SHA" ]; then
echo "::error::$tag already names $existing, not $SHA. Find out who created it."
exit 1
fi

# Not forced: prepare built the commit on the current skills, so this is a fast-forward,
# and if skills moved since, the push is refused rather than overwriting it.
git push "$remote" "$SHA:refs/heads/skills" "$SHA:refs/tags/$tag"
rm -f "$RUNNER_TEMP/deploy_key"
echo "skills and $tag now name $SHA."
4 changes: 3 additions & 1 deletion Trax.Docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ decision records for the rules that span more than one folder of the [Trax repos
|---|---|
| `*.md` and the section directories (`core/`, `effect/`, `mediator/`, `scheduler/`, `statemachine/`, `sdk-reference/`, `reference/`, `contributing/`, ...) | The pages published at traxsharp.net/docs. A file at `effect/metadata.md` is served at `/docs/effect/metadata`. |
| `adr/` | Architecture decision records that bind more than one folder, indexed in [`adr/README.md`](https://github.com/TraxSharp/Trax/blob/main/Trax.Docs/adr/README.md). Not published to the site. |
| `skills/` | Agent skills for apps built on Trax, one folder per skill. Not published to the site; after each release an approved run of the `Skills` workflow publishes them to the [`skills` branch](https://github.com/TraxSharp/Trax/tree/skills) ([ADR 0056](https://github.com/TraxSharp/Trax/blob/main/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md)). |
| `tools/Trax.Adr.Guard` | The checker for ADR frontmatter, index tables and guard census. CI builds it from this folder in the same checkout and runs it over each folder's ADR corpus through the local composite action `.github/actions/adr-guard` at the repository root, so a guard change and the corpus it checks land in one pull request ([ADR 0042](https://github.com/TraxSharp/Trax/blob/main/Trax.Docs/adr/0042-trax-is-one-repository-and-releases-at-one-version.md)). |
| `tests/Trax.Docs.Tests` | Lint for the pages: internal links and redirects resolve, no em dashes, no Jekyll syntax, every page has a type and an area, SDK reference pages name a package, namespace and source that exist, and links from the code to the docs resolve. |
| `tests/Trax.Docs.Snippets.Tests` | The code on the pages: every C# fence is marked, `compile` fences build against this checkout, `from=` fences match the file they quote. |
| `tests/Trax.Docs.Snippets.Tests` | The code on the pages: every C# fence is marked, `compile` fences build against this checkout, `from=` fences match the file they quote. Covers the skills' fences too. |
| `tools/Trax.Skills.Publish` | Checks the skills and stages the tree the `Skills` workflow commits to the `skills` branch: the release's version stamped in, the plugin manifest written. Its `marketplace` command prints the `.claude-plugin/marketplace.json` that `main` holds. Tested by `tests/Trax.Skills.Publish.Tests`. |

[Trax.Website](https://github.com/TraxSharp/Trax/tree/main/Trax.Website) renders these files. The site is published from a
release: the release workflow moves the `website` branch to each release tag, so the published docs describe released code
Expand Down
2 changes: 2 additions & 0 deletions Trax.Docs/Trax.Docs.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
<Project Path="tests/Trax.Adr.Guard.Tests/Trax.Adr.Guard.Tests.csproj" />
<Project Path="tests/Trax.Docs.Snippets.Tests/Trax.Docs.Snippets.Tests.csproj" />
<Project Path="tests/Trax.Docs.Tests/Trax.Docs.Tests.csproj" />
<Project Path="tests/Trax.Skills.Publish.Tests/Trax.Skills.Publish.Tests.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/Trax.Adr.Guard/Trax.Adr.Guard.csproj" />
<Project Path="tools/Trax.Skills.Publish/Trax.Skills.Publish.csproj" />
</Folder>
</Solution>
Loading
Loading