diff --git a/.github/workflows/sync-skills.yml b/.github/workflows/sync-skills.yml new file mode 100644 index 0000000..d1c6d97 --- /dev/null +++ b/.github/workflows/sync-skills.yml @@ -0,0 +1,166 @@ +name: Trigger AI Skills Sync on Release + +# Syncs on a published release rather than on merge to main: skills describe the +# CLI users have installed, and diffing main would document unreleased commands. +# +# Triggered by the Native Binary Release workflow rather than release.published. +# On the tag-first path in RELEASING.md the release is created by +# action-gh-release using GITHUB_TOKEN, and GitHub does not start workflow runs +# for GITHUB_TOKEN events, so release.published would never fire. Native Binary +# Release runs on the tag push either way. npm-publish.yml uses the same pattern. + +on: + workflow_run: + workflows: ["Native Binary Release"] + types: + - completed + workflow_dispatch: + inputs: + base_tag: + description: "Previous release tag (for example: v1.9.0). Leave empty to resolve it automatically." + required: false + type: string + head_tag: + description: "Release tag to sync from (for example: v1.9.1)" + required: true + type: string + +permissions: + contents: read + +# Serialize runs so two releases cannot open competing pull requests. The default +# queue holds only one pending run, which would drop the middle of three releases +# published in quick succession. +concurrency: + group: sync-skills + cancel-in-progress: false + queue: max + +jobs: + run-codex-skills-sync: + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - name: Resolve release range + id: range + env: + GH_TOKEN: ${{ github.token }} + # For a tag push, workflow_run reports the tag name as head_branch. + NEW_TAG: ${{ github.event.inputs.head_tag || github.event.workflow_run.head_branch }} + BASE_TAG_INPUT: ${{ github.event.inputs.base_tag }} + SKILLS_REPO_TOKEN: ${{ secrets.DOCS_REPO_TOKEN }} + run: | + set -euo pipefail + + # Corgea/skills is public, so a missing token clones fine and only + # fails on the final push, after a full Codex run. Fail fast instead. + if [[ -z "${SKILLS_REPO_TOKEN:-}" ]]; then + echo "::error::DOCS_REPO_TOKEN is unset. It needs write access to Corgea/skills." + exit 1 + fi + + # Native Binary Release also runs on branch pushes, where head_branch + # is a branch name with no release. Ask the API instead of matching on + # the ref: tag names are inconsistent here (v1.9.1 but 1.8.8). + if ! RELEASE_JSON=$(gh release view "$NEW_TAG" --repo "$GITHUB_REPOSITORY" \ + --json publishedAt,isDraft,isPrerelease,body 2>/dev/null); then + RELEASE_JSON="" + + if [[ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]]; then + echo "$NEW_TAG is not a published release (branch push?); nothing to sync." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A plain tag with no release still syncs if the caller names a base. + if [[ -z "$BASE_TAG_INPUT" ]]; then + echo "::error::No published GitHub Release found for tag $NEW_TAG, so the previous release cannot be resolved automatically. Re-run with base_tag set explicitly." + exit 1 + fi + echo "No GitHub Release for $NEW_TAG; continuing with the base_tag given." + fi + + # workflow_run carries no release payload, so filter drafts here. + # Manual dispatch names its tag deliberately and stays an override. + if [[ -n "$RELEASE_JSON" && "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]] \ + && [[ "$(jq -r '.isDraft' <<<"$RELEASE_JSON")" == "true" || \ + "$(jq -r '.isPrerelease' <<<"$RELEASE_JSON")" == "true" ]]; then + echo "$NEW_TAG is a draft or prerelease; nothing to sync." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ -n "$BASE_TAG_INPUT" ]]; then + PREV_TAG="$BASE_TAG_INPUT" + else + # Drafts report publishedAt as null, which sorts before every real + # timestamp and would select the newest release as the base. + NEW_PUBLISHED=$(jq -r '.publishedAt // ""' <<<"$RELEASE_JSON") + if [[ -z "$NEW_PUBLISHED" || "$NEW_PUBLISHED" == "null" ]]; then + echo "::error::Release $NEW_TAG has no publishedAt timestamp (draft?). Re-run with base_tag set explicitly." + exit 1 + fi + export NEW_PUBLISHED + + # Newest release published before this one. Comparing timestamps + # rather than just excluding NEW_TAG matters on manual dispatch: an + # older tag would otherwise pick a newer base and feed Codex a + # reversed diff, which reads as commands being removed. + # ISO-8601 UTC sorts lexicographically. + PREV_TAG=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ + --json tagName,isDraft,isPrerelease,publishedAt \ + --jq '[.[] | select(.isDraft == false and .isPrerelease == false and .publishedAt < env.NEW_PUBLISHED)] + | sort_by(.publishedAt) | reverse | .[0].tagName // ""') + fi + + if [[ -z "$PREV_TAG" ]]; then + echo "No previous release to diff against; nothing to sync." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Resolve through the API so annotated tags land on their commit. + BASE_SHA=$(gh api "repos/$GITHUB_REPOSITORY/commits/$PREV_TAG" --jq .sha) + HEAD_SHA=$(gh api "repos/$GITHUB_REPOSITORY/commits/$NEW_TAG" --jq .sha) + + echo "Syncing skills for $PREV_TAG ($BASE_SHA) -> $NEW_TAG ($HEAD_SHA)" + { + echo "skip=false" + echo "prev_tag=$PREV_TAG" + echo "new_tag=$NEW_TAG" + echo "base_sha=$BASE_SHA" + echo "head_sha=$HEAD_SHA" + } >> "$GITHUB_OUTPUT" + + # Release notes are multi-line and author-supplied, so end the output + # with a delimiter the body will not contain. + if [[ -n "$RELEASE_JSON" ]]; then + NOTES_DELIM="NOTES_EOF_$(date +%s)$RANDOM" + { + echo "notes<<${NOTES_DELIM}" + jq -r '.body // ""' <<<"$RELEASE_JSON" + echo "${NOTES_DELIM}" + } >> "$GITHUB_OUTPUT" + fi + + # No checkout on purpose: the action checks the CLI out into ./source. A + # checkout at the workspace root would leave the CLI's own + # skills/corgea/SKILL.md under Codex's working directory as a tempting + # wrong edit target, whose changes would then be silently discarded. + - name: Run AI Skills Sync + if: steps.range.outputs.skip == 'false' + uses: Corgea/internal_actions/sync-skills-action@main + with: + base_sha: ${{ steps.range.outputs.base_sha }} + head_sha: ${{ steps.range.outputs.head_sha }} + openai_api_key: ${{ secrets.OPENAI_API_KEY }} + # Shared bot token; the name is historical. It has write access to + # Corgea/skills as well as Corgea/docs, so rotating it affects the + # docs sync workflows too. + skills_repo_token: ${{ secrets.DOCS_REPO_TOKEN }} + skills_repo: "Corgea/skills" + skills_base_branch: "main" + release_info: | + Corgea CLI release ${{ steps.range.outputs.new_tag }} (previous release: ${{ steps.range.outputs.prev_tag }}). + Release notes: + ${{ steps.range.outputs.notes }}