Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a1f1f81
refactor(scripts): move deploy scripts and tests into scripts/
ldastey-dev Sep 6, 2026
c73e9a5
feat(overrides): add immutable base with consumer-owned override layer
ldastey-dev Sep 6, 2026
2a965a7
feat(update): add update and migrate tooling for deployed repos
invalid-email-address Sep 6, 2026
8c0eca0
ci(release): add version gate, PR title check and release automation
invalid-email-address Sep 6, 2026
6bcd4cb
docs(versioning): document the override model, versioning and update …
invalid-email-address Sep 6, 2026
641fb44
fix(ci): allow bootstrap of CI-owned files and restore executable bits
invalid-email-address Sep 6, 2026
ec6e6a0
fix(release): publish 1.0.0 as the initial drop instead of bumping pa…
invalid-email-address Sep 6, 2026
7a09baf
docs(readme): explain what each version bump means for a deployment
invalid-email-address Sep 6, 2026
f41aa5a
chore: ignore Node heap dump reports
invalid-email-address Sep 6, 2026
732fa7f
fix(content): repair broken index routes and separate migration baseline
ldastey-dev Sep 6, 2026
8317ba4
docs(readme): correct baseline ownership claim
ldastey-dev Sep 6, 2026
4124f11
fix(update,ci): close data-loss and version-derivation defects from r…
ldastey-dev Sep 6, 2026
189368b
fix(powershell): restore parity with the bash migrate and update fixes
ldastey-dev Sep 6, 2026
cad8b6e
fix(deploy,update): protect the override layer and fix cross-platform…
ldastey-dev Sep 6, 2026
60dff5d
fix(deploy): apply the overwrite guard to the shipped update tooling
ldastey-dev Sep 6, 2026
8a75dab
fix(update): track non-markdown base files and check the tooling copies
ldastey-dev Sep 6, 2026
e4430d3
fix(deploy): preserve consumer manifest configuration on redeploy
ldastey-dev Sep 6, 2026
59353d3
docs(core): use --quiet for the session-start update check
ldastey-dev Sep 6, 2026
5e98b5d
fix(update): escape the substitution delimiter in manifest key lookup
ldastey-dev Sep 6, 2026
a5aa682
fix(update): treat index.md as mandatory payload content
ldastey-dev Sep 6, 2026
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
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: 2

# Third-party actions are pinned to full commit SHAs so a retargeted tag cannot
# execute in our workflows. Dependabot is what keeps those pins current, so the
# hardening does not decay into running abandoned versions.
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
commit-message:
# Workflow files are not deployable, so this never cuts a release.
prefix: ci
labels:
- dependencies
18 changes: 15 additions & 3 deletions .github/workflows/deploy-ps1-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,19 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
Import-Module PSScriptAnalyzer
$results = Invoke-ScriptAnalyzer -Path ./deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1
# Every PowerShell file that ships to a consumer must clear the 5.1
# baseline, not just deploy.ps1 - update and migrate run on their
# machines too.
$targets = @(
'./scripts/deploy.ps1',
'./scripts/update.ps1',
'./scripts/migrate.ps1',
'./scripts/lib/common.ps1'
)
$results = @()
foreach ($target in $targets) {
$results += Invoke-ScriptAnalyzer -Path $target -Settings ./PSScriptAnalyzerSettings.psd1
}
if ($results) {
$results | Format-Table -AutoSize | Out-String | Write-Host
throw "PSScriptAnalyzer reported $($results.Count) compatibility finding(s)."
Expand Down Expand Up @@ -110,7 +122,7 @@ jobs:
}

- name: Run Pester unit tests
run: Invoke-Pester -Path ./deploy.Tests.ps1 -CI
run: Invoke-Pester -Path ./scripts/deploy.Tests.ps1 -CI

- name: Run end-to-end deploy tests
run: pwsh ./tests/test-deploy.ps1
run: pwsh ./scripts/tests/test-deploy.ps1
68 changes: 65 additions & 3 deletions .github/workflows/deploy-sh-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,68 @@ jobs:
done < <(git ls-files '*.sh')
exit $status

- name: Executable bit on entry-point scripts
run: |
# A lost executable bit surfaces as an opaque exit 126 in the test job,
# so check it here where the cause is obvious. Only scripts that are
# invoked directly need it; lib/common.sh is sourced, not run.
status=0
for script in \
scripts/deploy.sh \
scripts/update.sh \
scripts/migrate.sh \
scripts/tests/test-deploy.sh \
scripts/ci/next-version.sh \
scripts/ci/write-baseline.sh \
scripts/ci/write-changelog.sh; do
mode="$(git ls-files -s "$script" | cut -d' ' -f1)"
if [ "$mode" != "100755" ]; then
echo "::error file=$script::$script is mode $mode; it must be 100755. Run: git update-index --chmod=+x $script"
status=1
fi
done
exit $status

- name: Deployable paths cover everything that ships
run: |
# is_deployable in next-version.sh decides whether a change cuts a
# release. If a file reaches consumers but is not listed there, the
# change silently never releases and the omission is only discovered
# when a consumer notices missing content. A rule this load-bearing
# must not depend on remembering a checklist item.
set -uo pipefail
status=0
# Everything the deploy scripts copy into a target repository.
shipped="core standards playbooks
scripts/deploy.sh scripts/deploy.ps1
scripts/update.sh scripts/update.ps1
scripts/migrate.sh scripts/migrate.ps1
scripts/lib/common.sh scripts/lib/common.ps1"
for path in $shipped; do
if [ -d "$path" ]; then
probe="$path/__probe__.md"
else
probe="$path"
fi
printf '%s\n' "$probe" > /tmp/probe.txt
result="$(scripts/ci/next-version.sh --changed-files /tmp/probe.txt --subject 'fix: probe' --latest-tag v1.0.0)"
if [ "$result" = "none" ]; then
echo "::error file=scripts/ci/next-version.sh::'$probe' is deployed to consumers but is_deployable does not match it, so changing it would never cut a release."
status=1
fi
done
# And the inverse: non-deployable paths must not trigger releases.
for path in README.md AGENTS.md .github/workflows/release.yml scripts/ci/next-version.sh scripts/tests/test-deploy.sh; do
printf '%s\n' "$path" > /tmp/probe.txt
result="$(scripts/ci/next-version.sh --changed-files /tmp/probe.txt --subject 'fix: probe' --latest-tag v1.0.0)"
if [ "$result" != "none" ]; then
echo "::error file=scripts/ci/next-version.sh::'$path' never reaches a consumer but is_deployable matches it, so documentation and CI changes would cut releases."
status=1
fi
done
[ "$status" -eq 0 ] && echo "OK: deployable paths and is_deployable agree."
exit $status

test:
name: ${{ matrix.name }}
strategy:
Expand All @@ -81,19 +143,19 @@ jobs:
echo "/bin/bash: $(/bin/bash --version | head -1)"

- name: Run end-to-end deploy tests
run: bash tests/test-deploy.sh
run: bash scripts/tests/test-deploy.sh

# macOS ships bash 3.2 as /bin/bash and deploy.sh declares #!/bin/bash,
# so consumers on macOS run it under 3.2. Assert that explicitly instead of
# relying on whichever bash happens to be first on PATH.
- name: Run end-to-end deploy tests under /bin/bash
if: runner.os == 'macOS'
run: /bin/bash tests/test-deploy.sh
run: /bin/bash scripts/tests/test-deploy.sh

- name: Verify deploy.sh runs from an arbitrary working directory
run: |
target="$(mktemp -d)"
cd "$(mktemp -d)"
"$GITHUB_WORKSPACE/deploy.sh" --agents all --overwrite "$target"
"$GITHUB_WORKSPACE/scripts/deploy.sh" --agents all --overwrite "$target"
test -f "$target/AGENTS.md"
test -f "$target/.context/index.md"
55 changes: 55 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: PR title

# The release job derives the version bump size from the merge commit subject,
# which is the pull request title when a PR is squashed. If the title is not a
# valid Conventional Commit the bump cannot be derived, so the title is checked
# on every pull request rather than discovered at merge time.
on:
pull_request:
types: [opened, edited, reopened, synchronize]

concurrency:
group: pr-title-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
pull-requests: read

jobs:
lint:
name: Conventional Commit title
runs-on: ubuntu-latest
steps:
# Pinned to a full commit SHA, not a mutable tag: a compromised or
# retargeted "v5" tag would otherwise execute here. Dependabot raises the
# upgrade, so pinning costs nothing in currency.
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# This list is PR hygiene, not the bump decision. It keeps changelog
# groups meaningful by rejecting invented types before merge.
#
# next-version.sh deliberately does NOT share this list: it applies a
# patch floor to any unrecognised type, so a deployable change that
# reaches main by some route this lint never saw - a direct push, or
# a non-squash merge - still cuts a release instead of being lost.
# The lint prevents unknown types; the floor makes them harmless.
types: |
feat
fix
docs
style
refactor
perf
test
build
ci
chore
revert
requireScope: false
# A single-line subject keeps the squashed commit parseable.
subjectPattern: ^(?![A-Z])(?!.*\.$).+$
subjectPatternError: |
The subject "{subject}" is invalid: it must start with a lower-case
letter and must not end with a full stop.
181 changes: 181 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
name: Release

# Cuts a release when deployable content lands on main. This is the only writer
# of VERSION, CHANGELOG.md, tags and scripts/baselines/, which is what lets a
# deployed consumer trust a single 6-byte fetch of VERSION to detect staleness.
on:
push:
branches: [main]

# Releases must queue, never cancel. Cancelling an in-flight release could leave
# a tag pointing at a commit whose VERSION was never pushed.
concurrency:
group: release-main
cancel-in-progress: false

permissions:
contents: write

jobs:
release:
name: Cut release
runs-on: ubuntu-latest
# Loop guard, belt and braces: the release commit is authored by the Actions
# bot and carries [skip ci]. Either condition alone would do; both together
# mean a change to one convention cannot cause an infinite release loop.
if: >-
github.actor != 'github-actions[bot]' &&
!contains(github.event.head_commit.message, '[skip ci]')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Persist the token so the later push and tag reuse it.
persist-credentials: true

- name: Resolve changed files for this push
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
run: |
set -euo pipefail
# github.event.before is all-zeroes for the first push to a new branch
# and can point at a commit that no longer exists after a force-push.
# Fall back to the first-parent diff, which is the squashed merge.
if [ -z "${BEFORE//0/}" ] || ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
git show --name-only --pretty=format: "$AFTER" > /tmp/changed-files.txt
else
git diff --name-only "$BEFORE" "$AFTER" > /tmp/changed-files.txt
fi
grep -v '^$' /tmp/changed-files.txt > /tmp/changed-files.tmp || true
mv /tmp/changed-files.tmp /tmp/changed-files.txt
echo "Changed files:"
sed 's/^/ /' /tmp/changed-files.txt

- name: Compute next version
id: next
run: |
set -euo pipefail
subject="$(git log -1 --pretty=format:'%s')"
Comment thread
ldastey-dev marked this conversation as resolved.
# Pass the whole message, not just the subject: Conventional Commits
# puts BREAKING CHANGE in the footer, so a subject-only read ships a
# breaking change as a patch to consumers pinned to a major line.
git log -1 --pretty=format:'%B' > /tmp/commit-body.txt
current="$(tr -d ' \t\r\n' < VERSION)"
prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)"

# The bump size is read from the merge subject, which equals the PR
# title only under squash-merge. Under a merge commit the subject is
# "Merge pull request #..." and under rebase-merge it is the last
# commit's subject, so pr-title.yml would be validating a string that
# never reaches this job. Warn loudly rather than silently defaulting
# to a patch, which would understate a feat or a breaking change.
if ! printf '%s' "$subject" | grep -Eq '^[a-zA-Z]+(\([^)]*\))?!?:'; then
echo "::warning::Merge subject '$subject' is not a Conventional Commit. \
The repository must use squash-merge so the PR title becomes the merge subject; \
falling back to the patch floor."
{
echo "> **Merge subject is not a Conventional Commit.**"
echo "> \`$subject\`"
echo ">"
echo "> Bump size fell back to the patch floor. Enable squash-merge only."
} >> "$GITHUB_STEP_SUMMARY"
fi

next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject" --body-file /tmp/commit-body.txt --latest-tag "$prev_tag")"
echo "current=$current" >> "$GITHUB_OUTPUT"
echo "next=$next" >> "$GITHUB_OUTPUT"
echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT"
if [ "$next" = "none" ]; then
echo "No deployable content changed. Skipping release." >> "$GITHUB_STEP_SUMMARY"
elif [ -z "$prev_tag" ]; then
echo "Initial release: publishing $next as-is." >> "$GITHUB_STEP_SUMMARY"
else
echo "Releasing $current -> $next" >> "$GITHUB_STEP_SUMMARY"
fi

- name: Fail if the release tag already exists
if: steps.next.outputs.next != 'none'
env:
NEXT: ${{ steps.next.outputs.next }}
run: |
set -euo pipefail
# Re-releasing an existing version would move a tag consumers have
# already pinned to, so stop rather than overwrite.
if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then
echo "::error::Tag v$NEXT already exists. Refusing to re-release it."
exit 1
fi

- name: Write VERSION, CHANGELOG and baseline
if: steps.next.outputs.next != 'none'
env:
NEXT: ${{ steps.next.outputs.next }}
PREV_TAG: ${{ steps.next.outputs.prev_tag }}
run: |
set -euo pipefail
printf '%s\n' "$NEXT" > VERSION
# The baseline must hash the released content, so write it after
# VERSION but from the same working tree that is about to be tagged.
scripts/ci/write-baseline.sh "$NEXT"
scripts/ci/write-changelog.sh "$NEXT" "$PREV_TAG"

- name: Commit, then tag that commit
if: steps.next.outputs.next != 'none'
env:
NEXT: ${{ steps.next.outputs.next }}
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add VERSION CHANGELOG.md "scripts/baselines/$NEXT.sha256"

# On an initial release VERSION and the baseline are already correct
# in the tree, so there may be nothing to commit. That is not a
# failure - the tag still has to be created against this commit.
if git diff --cached --quiet; then
echo "Nothing to commit; tagging the current commit."
else
git commit -m "chore(release): $NEXT [skip ci]"

# Push the commit before tagging it. Tagging first would produce a
# tag whose tree still holds the previous VERSION, so a consumer
# resolving the tag would download content that disagrees with the
# version they were told to expect.
#
# Another release may have landed while this job queued, so rebase
# and retry rather than failing the release outright.
attempt=1
until git push origin HEAD:main; do
if [ "$attempt" -ge 3 ]; then
echo "::error::Could not push the release commit after $attempt attempts."
exit 1
fi
echo "Push rejected; rebasing onto the latest main (attempt $attempt)."
git fetch origin main
git rebase origin/main
attempt=$((attempt + 1))
done
fi

git tag -a "v$NEXT" -m "v$NEXT" HEAD
git push origin "v$NEXT"

- name: Publish GitHub release
if: steps.next.outputs.next != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEXT: ${{ steps.next.outputs.next }}
run: |
set -euo pipefail
# Extract just this version's section from the changelog for the notes.
awk -v ver="## $NEXT " '
index($0, ver) == 1 { inside = 1; next }
/^## / && inside { exit }
inside { print }
' CHANGELOG.md > /tmp/notes.md
[ -s /tmp/notes.md ] || echo "See CHANGELOG.md." > /tmp/notes.md
gh release create "v$NEXT" \
--title "v$NEXT" \
--notes-file /tmp/notes.md \
"scripts/baselines/$NEXT.sha256#baseline-$NEXT.sha256"
Loading
Loading