Sync master from dev #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |