Skip to content

Sync master from dev #2

Sync master from dev

Sync master from dev #2

Workflow file for this run

name: Sync master from dev
on:
push:
tags:
- "*@v*.*.*" # skill release tags: kleros-curate@v1.0.0, kleros-ipfs-upload@v1.1.0
- "v[0-9]*" # repo-level/whole-plugin releases: v1.2.0 (REV-05: digit-anchored to skip vNext, vendor-*, etc.)
workflow_dispatch: # manual override for hotfixes / initial dry-run (gated by production-sync Environment)
# Principle of least privilege for the implicit GITHUB_TOKEN.
# The actual master push uses the GitHub App token minted below — this block
# only restricts what the workflow's auto-injected GITHUB_TOKEN can do.
permissions:
contents: read
# Never drop a running sync — queue the next one instead.
concurrency:
group: sync-master
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 15 # hard cap; default is 6h — refuse to silently burn budget
# REV-07: gate the job on a protected Environment.
# Configure `production-sync` in repo Settings → Environments with:
# - Required reviewers: org owners only (or named maintainers)
# - Deployment branches: restrict to `dev` (defense in depth)
# workflow_dispatch then requires reviewer approval before the App token
# is minted, closing the privilege-amplification vector for non-tag runs.
environment: production-sync
steps:
# -----------------------------------------------------------------------
# 1. Mint a short-lived GitHub App installation token (auto-revoked at job end).
# App must be registered in the kleros org, scoped to this repo only,
# with Contents: Read and write. No other permissions needed.
#
# SHA-pinned (REV-02): this Action sees GH_APP_PRIVATE_KEY as input.
# A mutable @v2 tag would let a compromised release exfiltrate the PEM.
# Update the pin alongside any deliberate version bump.
# -----------------------------------------------------------------------
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
# -----------------------------------------------------------------------
# 2. Check out dev (shallow — full history not needed for strip+push).
# -----------------------------------------------------------------------
- name: Checkout dev
uses: actions/checkout@v4
with:
ref: dev
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 1
# Fetch origin/master so --force-with-lease has a known remote-tracking ref.
# The "|| true" handles the edge case where master does not yet exist on origin
# (e.g. very first bootstrap run before any master commits).
- name: Fetch origin/master for force-with-lease safety
run: git fetch origin master:refs/remotes/origin/master || true
# -----------------------------------------------------------------------
# REV-03: Tag ancestry check.
# Verify the triggering commit is reachable from origin/dev before any
# destructive operation. Closes the privilege-amplification vector where
# a contributor pushes a tag at an arbitrary (e.g. malicious) commit.
#
# github.sha is a 40-char hex hash from GitHub itself (not user-controllable
# text), so direct interpolation in `run:` is safe. The env-var pattern is
# used anyway as defensive coding consistent with the workflow's other steps.
#
# Skipped for workflow_dispatch — that path is gated by the Environment
# required-reviewers check (REV-07).
# -----------------------------------------------------------------------
- name: Verify trigger commit is on dev ancestry
if: github.event_name == 'push'
env:
TRIGGER_SHA: ${{ github.sha }}
TRIGGER_REF: ${{ github.ref_name }}
run: |
# Fetch enough dev history for the merge-base lookup.
# 500 commits is generous; the existing checkout was --depth=1.
git fetch origin dev --depth=500
if ! git merge-base --is-ancestor "$TRIGGER_SHA" origin/dev; then
echo "ERROR: tag $TRIGGER_REF (commit $TRIGGER_SHA) is not reachable"
echo " from origin/dev. Refusing to sync master — release tags"
echo " must point to commits on dev's history."
exit 1
fi
echo "OK: $TRIGGER_REF ($TRIGGER_SHA) is on dev ancestry."
# -----------------------------------------------------------------------
# 3. Set up Node for the quality gates.
# -----------------------------------------------------------------------
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: "20"
# -----------------------------------------------------------------------
# 4. Quality gate — test suite must pass before we touch master.
# -----------------------------------------------------------------------
- name: Quality gate — npm test
run: npm test
# -----------------------------------------------------------------------
# 5. Quality gate — index.json digests must be fresh.
# Re-running update-digests and checking for a diff ensures no stale
# SHA-256 hashes ship to user machines.
# -----------------------------------------------------------------------
- name: Quality gate — digest freshness
run: |
node scripts/update-digests.js
if ! git diff --quiet .well-known/agent-skills/index.json; then
echo "ERROR: .well-known/agent-skills/index.json digests are stale."
echo "Run 'npm run update-digests' locally and commit the result before tagging."
exit 1
fi
# -----------------------------------------------------------------------
# 6. Strip dev-only files.
# Everything removed here will NOT appear on master or in plugin installs.
#
# Strip-list:
# .planning/ — GSD planning artefacts
# test/ — test suite
# scripts/ — dev helper scripts
# package.json — dev dependencies / npm scripts
# yarn.lock — lockfile
# .yarnrc.yml — Yarn config
# *_FEEDBACK*.md — AI session feedback scratch files (root only)
# HANDOVER*.md — Handover scratch files (root only)
# -----------------------------------------------------------------------
- name: Strip dev-only files
run: |
git rm -r --quiet --ignore-unmatch \
.planning \
test \
scripts \
package.json \
yarn.lock \
.yarnrc.yml
# Remove root feedback/handover md files by pattern.
# REV-01: root-anchored with [^/]* — previous '^.*_FEEDBACK.*\.md$' over-matched
# paths at ANY depth (e.g. kleros-curate/MIGRATION_FEEDBACK.md), which would
# silently delete legitimate skill-dir docs from user installs.
git ls-files | grep -E '^[^/]*_FEEDBACK[^/]*\.md$|^HANDOVER[^/]*\.md$' | \
xargs --no-run-if-empty git rm --quiet
echo "Strip complete. Remaining tracked files:"
git ls-files | head -60
# -----------------------------------------------------------------------
# REV-09: Sanity check the stripped tree before committing.
# Catches both over-strip (keep-list path disappeared) and under-strip
# (strip-list path survived). Cheap insurance against future strip-list
# edits that go wrong.
# -----------------------------------------------------------------------
- name: Sanity check stripped tree
run: |
# strip-list assertions — must be ABSENT
for path in .planning test scripts package.json yarn.lock .yarnrc.yml; do
if [ -e "$path" ]; then
echo "FATAL: strip-list path '$path' still present after strip"
exit 1
fi
done
# Root feedback/handover files must be ABSENT
if compgen -G "*_FEEDBACK*.md" > /dev/null 2>&1 || \
compgen -G "HANDOVER*.md" > /dev/null 2>&1; then
echo "FATAL: root *_FEEDBACK*.md or HANDOVER*.md still present"
exit 1
fi
# keep-list assertions — must be PRESENT (catches strip overreach)
for path in kleros-curate kleros-ipfs-upload .well-known/agent-skills \
.github/workflows SKILL.md index.html README.md LICENSE \
CHANGELOG.md robots.txt sitemap.xml; do
if [ ! -e "$path" ]; then
echo "FATAL: keep-list path '$path' missing after strip — overreach"
exit 1
fi
done
echo "OK: stripped tree looks correct."
# -----------------------------------------------------------------------
# 7. Configure git identity for the bot commit on master.
# REV-11: identity derived dynamically from the App token output.
# Whatever name the user registered the App with (e.g. kleros-skills-bot
# or kleros-sync), the commit author will match the actual App slug —
# no hardcoded string to drift out of sync.
# -----------------------------------------------------------------------
- name: Configure git identity
env:
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
git config user.name "${APP_SLUG}[bot]"
git config user.email "${APP_SLUG}[bot]@users.noreply.github.com"
# -----------------------------------------------------------------------
# 8. Commit the stripped tree and push to master.
# --force-with-lease rejects the push if someone manually pushed to
# master between our checkout and this step (safer than --force).
# The commit is created even when deletions are the only changes.
# -----------------------------------------------------------------------
- name: Commit stripped tree and push to master
run: |
# If nothing changed after strip, master is already in sync — skip.
STAGED=$(git diff --cached --name-only | wc -l)
UNSTAGED=$(git diff --name-only | wc -l)
if [ "$STAGED" -eq 0 ] && [ "$UNSTAGED" -eq 0 ]; then
echo "No changes after strip — master is already up to date."
exit 0
fi
git commit -m "chore(master): sync from dev (strip dev-only files)"
git push origin HEAD:master --force-with-lease