diff --git a/.github/prompts/release-content.prompt.md b/.github/prompts/release-content.prompt.md index 0e196c0..d701e00 100644 --- a/.github/prompts/release-content.prompt.md +++ b/.github/prompts/release-content.prompt.md @@ -11,7 +11,7 @@ refresh, first run `.github/prompts/update-book.prompt.md`. **What changed:** **Baseline release:** **Version bump:** -**Publication boundary:** open a PR and stop; a human reviews and merges it. +**Publication boundary:** open a PR and stop; owner approval authorizes the separate release controller. ## Sources of truth @@ -85,14 +85,16 @@ refresh, first run `.github/prompts/update-book.prompt.md`. 8. **Open a PR using the available PR-creation tool, or update the existing preparation PR for this branch.** Do not create duplicate handoffs. Describe the baseline/target, reader-visible changes, verification, and editorial verdict. Wait for the validation - workflow and leave the PR for human review. Do not merge, create a release/tag, or - dispatch publishing as part of preparation. + workflow and leave the PR for human review. Keep infrastructure changes out of a content + PR. Do not merge, create a release/tag, or dispatch publishing as part of preparation. -## After the human merges +## After owner approval -`validate-book.yml` builds and gates the exact artifacts consumed by both publishing -workflows. `deploy-pages.yml` publishes the online site without the gated PDF; -`release-content.yml` creates `content-v` with the matching +The owner reviews a non-owner-authored PR, or comments `/release-book ` +on their own PR (GitHub does not allow self-approval). The controller rechecks the exact +head and content-only scope, validates and merges it, then explicitly validates the merge +and publishes the online site without the gated PDF. `release-content.yml` creates +`content-v` with the matching `gh-aw-book-v.pdf` and changelog notes. Verify both workflow outcomes, the release asset, and the live version-history page before calling the edition published. diff --git a/.github/prompts/update-book.prompt.md b/.github/prompts/update-book.prompt.md index 6719654..266ccc9 100644 --- a/.github/prompts/update-book.prompt.md +++ b/.github/prompts/update-book.prompt.md @@ -145,7 +145,9 @@ completed preparation handoff, not a claim of publication. On a later fresh upda ignore prepared historical plans when deciding whether to resolve a new latest target. The reusable validation workflow checks source/evidence, pinned compilation, and site/PDF builds before -the existing publishing workflows can run. Leave the PR for human review and merge. +publication. Leave the content-only PR for owner approval; the release controller handles +an eligible approved merge. Keep workflow, script, dependency and presentation changes +in a separate PR. ## Coordination and stopping rules diff --git a/.github/skills/playbook-orchestration/SKILL.md b/.github/skills/playbook-orchestration/SKILL.md index 94cb7a5..00f6cd1 100644 --- a/.github/skills/playbook-orchestration/SKILL.md +++ b/.github/skills/playbook-orchestration/SKILL.md @@ -93,7 +93,8 @@ For each chapter in the wave, run **research in parallel**, then author, verify, Covered changes invalidate acceptance and require renewed review. 9. `frontend-builder` regenerates the existing site/PDF. Run the `release-content` prompt to choose an appropriate prose edition, update notes, and prepare a PR. The reusable - validation workflow gates publishing; human review and merge remain the release boundary. + validation workflow gates publishing; explicit owner approval triggers the separate + content-only release controller. Mark the plan prepared with its edition, source fingerprint and PR URL, without claiming publication. Retries reuse the appropriate proposed edition rather than bumping twice. diff --git a/.github/workflows/book-review-signal.yml b/.github/workflows/book-review-signal.yml new file mode 100644 index 0000000..36ee2cc --- /dev/null +++ b/.github/workflows/book-review-signal.yml @@ -0,0 +1,13 @@ +name: Book review signal + +on: + pull_request_review: + types: [submitted, dismissed] + +permissions: {} + +jobs: + signal: + runs-on: ubuntu-latest + steps: + - run: echo "Review event received; trusted controller independently checks authorization." diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 32776b4..c14947d 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -9,8 +9,6 @@ on: branches: [main] paths: - "site/**" - - "content/**" - - "examples/**" - "assets/**" - "scripts/**" - "analytics/**" @@ -28,7 +26,23 @@ concurrency: cancel-in-progress: true jobs: + published-source: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Require content to match the last published edition + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="$(python -c "import sys; sys.path.insert(0,'scripts'); import release_content as r; print(r.select_base(r.ROOT, env={})[0])")" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft == false' | grep -qx true + git diff --quiet "$TAG" HEAD -- content examples validate: + needs: published-source uses: ./.github/workflows/validate-book.yml with: artifact-name: validated-pages @@ -38,7 +52,13 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pages: write steps: + - name: Checkout trusted Pages helper + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false - name: Download the validated site and analytics beacon uses: actions/download-artifact@v4 with: @@ -50,6 +70,8 @@ jobs: - name: Publish the validated site to gh-pages run: | + test "$(GH_TOKEN="$GITHUB_TOKEN" gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)" = "$SOURCE_SHA" + GH_TOKEN="$GITHUB_TOKEN" python scripts/publish_pages.py check cd artifact/site touch .nojekyll git init -q -b gh-pages @@ -57,7 +79,9 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A git commit -q -m "Deploy ${SOURCE_SHA} from ${GITHUB_REF_NAME}" + DEPLOY_SHA="$(git rev-parse HEAD)" git push -q --force "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" gh-pages + GH_TOKEN="$GITHUB_TOKEN" python "$GITHUB_WORKSPACE/scripts/publish_pages.py" build --commit "$DEPLOY_SHA" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SOURCE_SHA: ${{ needs.validate.outputs.source-sha }} diff --git a/.github/workflows/release-approved-book.yml b/.github/workflows/release-approved-book.yml new file mode 100644 index 0000000..365595e --- /dev/null +++ b/.github/workflows/release-approved-book.yml @@ -0,0 +1,124 @@ +name: Release approved book + +on: + issue_comment: + types: [created, edited, deleted] + workflow_run: + workflows: ["Book review signal", "Check book update"] + types: [completed] + workflow_dispatch: + inputs: + pull-request: + description: "Content PR number for an explicit retry" + required: true + type: number + schedule: + - cron: "17 */6 * * *" + +permissions: + contents: read + pull-requests: read + checks: read + +concurrency: + group: release-approved-book + cancel-in-progress: false + +jobs: + select: + if: >- + github.ref == 'refs/heads/main' && + (github.event_name != 'issue_comment' || + (github.event.issue.pull_request && github.event.comment.user.login == 'webmaxru' && + github.event.action != 'deleted' && startsWith(github.event.comment.body, '/release-book '))) + runs-on: ubuntu-latest + outputs: + number: ${{ steps.select.outputs.number }} + head: ${{ steps.select.outputs.head }} + base: ${{ steps.select.outputs.base }} + comparison: ${{ steps.select.outputs.comparison }} + merge-sha: ${{ steps.select.outputs.merge_sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + - id: select + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + COMMENT_PR: ${{ github.event.issue.pull_request.url && github.event.issue.number || '' }} + INPUT_PR: ${{ inputs.pull-request }} + run: | + args=() + if [[ "$EVENT_NAME" == "issue_comment" ]]; then + if [[ -z "$COMMENT_PR" ]]; then exit 0; fi + args=(--number "$COMMENT_PR") + elif [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + args=(--number "$INPUT_PR") + fi + python scripts/approved_book.py select "${args[@]}" > selected.json + python - <<'PY' + import json, os, subprocess + data = json.load(open("selected.json")) + if data: + comparison = data["comparison"] + subprocess.run(["git", "merge-base", "--is-ancestor", comparison, data["base"]], check=True) + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + for key in ("number", "head", "base"): + print(f"{key}={data[key]}", file=out) + print(f"comparison={comparison}", file=out) + print(f"merge_sha={data.get('merge_sha', '')}", file=out) + PY + + preflight: + needs: select + if: needs.select.outputs.number != '' && needs.select.outputs.merge-sha == '' + uses: ./.github/workflows/validate-book.yml + with: + source-ref: ${{ needs.select.outputs.head }} + comparison-base: ${{ needs.select.outputs.comparison }} + artifact-name: approved-head + require-content-change: true + + merge: + needs: [select, preflight] + if: needs.select.outputs.number != '' && needs.select.outputs.merge-sha == '' && needs.preflight.outputs.source-sha == needs.select.outputs.head + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + checks: read + outputs: + merge-sha: ${{ steps.merge.outputs.merge_sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + - id: merge + env: + GH_TOKEN: ${{ github.token }} + NUMBER: ${{ needs.select.outputs.number }} + HEAD: ${{ needs.select.outputs.head }} + BASE: ${{ needs.select.outputs.base }} + COMPARISON: ${{ needs.select.outputs.comparison }} + run: | + python scripts/approved_book.py merge --number "$NUMBER" --head "$HEAD" --base "$BASE" --comparison "$COMPARISON" > merged.json + python -c 'import json,os; print("merge_sha="+json.load(open("merged.json"))["merge_sha"], file=open(os.environ["GITHUB_OUTPUT"],"a"))' + + publish: + needs: [select, merge] + if: always() && needs.select.result == 'success' && needs.select.outputs.number != '' && (needs.merge.result == 'success' || needs.select.outputs.merge-sha != '') + uses: ./.github/workflows/release-content.yml + permissions: + contents: write + pull-requests: read + checks: read + pages: write + with: + pull-request: ${{ needs.select.outputs.number }} + source-sha: ${{ needs.merge.outputs.merge-sha || needs.select.outputs.merge-sha }} + merge-base: ${{ needs.select.outputs.base }} + comparison-base: ${{ needs.select.outputs.comparison }} diff --git a/.github/workflows/release-content.yml b/.github/workflows/release-content.yml index 2551ac0..3dfeb75 100644 --- a/.github/workflows/release-content.yml +++ b/.github/workflows/release-content.yml @@ -1,21 +1,25 @@ -name: Release book content +name: Publish approved book -# Content editions are independent of tooling/framework pins. Existing complete -# releases are no-ops. Missing assets are built from their existing tag, never -# from a newer checkout that happens to have the same content/VERSION. on: - push: - branches: [main] - paths: - - "content/**" - - "examples/**" - - "scripts/**" - - ".github/workflows/release-content.yml" - - ".github/workflows/validate-book.yml" - workflow_dispatch: + workflow_call: + inputs: + pull-request: + type: string + required: true + source-sha: + type: string + required: true + merge-base: + type: string + required: true + comparison-base: + type: string + required: true permissions: contents: read + pull-requests: read + checks: read concurrency: group: release-content @@ -29,82 +33,131 @@ jobs: version: ${{ steps.plan.outputs.version }} tag: ${{ steps.plan.outputs.tag }} asset: ${{ steps.plan.outputs.asset }} - source_sha: ${{ steps.plan.outputs.source_sha }} - framework_version: ${{ steps.plan.outputs.framework_version }} - comparison_base: ${{ steps.plan.outputs.comparison_base }} - tag_exists: ${{ steps.plan.outputs.tag_exists }} + draft: ${{ steps.plan.outputs.draft }} + tag-exists: ${{ steps.plan.outputs.tag_exists }} steps: - - name: Checkout the triggering source and all edition tags + - name: Checkout trusted control code uses: actions/checkout@v4 with: + ref: main + path: tooling fetch-depth: 0 persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@v5 + - name: Checkout approved merged source + uses: actions/checkout@v4 with: - python-version: "3.12" - - - name: Select create, exact-tag repair, or idempotent skip + ref: ${{ inputs.source-sha }} + path: source + fetch-depth: 0 + persist-credentials: false + - name: Verify the approved PR and exact merge + env: + GH_TOKEN: ${{ github.token }} + NUMBER: ${{ inputs.pull-request }} + SOURCE_SHA: ${{ inputs.source-sha }} + MERGE_BASE: ${{ inputs.merge-base }} + run: | + python tooling/scripts/approved_book.py verify-merged --number "$NUMBER" --merge-sha "$SOURCE_SHA" --base "$MERGE_BASE" + git -C tooling merge-base --is-ancestor "$SOURCE_SHA" HEAD + - name: Select immutable publication action id: plan env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ inputs.source-sha }} + COMPARISON: ${{ inputs.comparison-base }} run: | - python scripts/release_content.py release-plan --repo "$GITHUB_REPOSITORY" > release-plan.json - cat release-plan.json + python tooling/scripts/release_content.py --root source release-plan --repo "$GITHUB_REPOSITORY" --base "$COMPARISON" --expected-source "$SOURCE_SHA" > plan.json + cat plan.json python - <<'PY' import json, os - from pathlib import Path - data = json.loads(Path("release-plan.json").read_text()) - with open(os.environ["GITHUB_OUTPUT"], "a") as output: + data = json.load(open("plan.json")) + if data["action"] != "skip" and not data.get("tag_exists"): + import subprocess + main = subprocess.check_output(["git", "-C", "tooling", "rev-parse", "HEAD"], text=True).strip() + if main != os.environ["SOURCE_SHA"]: + raise SystemExit("Cannot create a release for an outdated main commit.") + with open(os.environ["GITHUB_OUTPUT"], "a") as out: for key, value in data.items(): if isinstance(value, bool): value = str(value).lower() - output.write(f"{key}={value}\n") + print(f"{key}={value}", file=out) PY validate: needs: plan - if: needs.plan.outputs.action != 'skip' uses: ./.github/workflows/validate-book.yml with: - source-ref: ${{ needs.plan.outputs.source_sha }} - comparison-base: ${{ needs.plan.outputs.comparison_base }} + source-ref: ${{ inputs.source-sha }} + comparison-base: ${{ inputs.comparison-base }} artifact-name: validated-release + require-content-change: true release: needs: [plan, validate] runs-on: ubuntu-latest permissions: contents: write + pull-requests: read + checks: read env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ACTION: ${{ needs.plan.outputs.action }} + GH_TOKEN: ${{ github.token }} TAG: ${{ needs.plan.outputs.tag }} VERSION: ${{ needs.plan.outputs.version }} ASSET: ${{ needs.plan.outputs.asset }} - SOURCE_SHA: ${{ needs.plan.outputs.source_sha }} - FRAMEWORK_VERSION: ${{ needs.plan.outputs.framework_version }} - TAG_EXISTS: ${{ needs.plan.outputs.tag_exists }} + ACTION: ${{ needs.plan.outputs.action }} + DRAFT: ${{ needs.plan.outputs.draft }} + TAG_EXISTS: ${{ needs.plan.outputs.tag-exists }} + SOURCE_SHA: ${{ inputs.source-sha }} steps: - - name: Download the gated artifact, without rebuilding - uses: actions/download-artifact@v4 + - name: Checkout trusted authorization code + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + - name: Recheck approval and source before publication + env: + NUMBER: ${{ inputs.pull-request }} + MERGE_BASE: ${{ inputs.merge-base }} + run: | + python scripts/approved_book.py verify-merged --number "$NUMBER" --merge-sha "$SOURCE_SHA" --base "$MERGE_BASE" + if [[ "$ACTION" != "skip" && "$TAG_EXISTS" != "true" ]]; then + test "$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)" = "$SOURCE_SHA" + fi + - uses: actions/download-artifact@v4 with: name: validated-release path: artifact - - - name: Check artifact identity and prepare release notes + - name: Verify artifact and prohibit older releases becoming latest + id: versions run: | python - <<'PY' import json, os - from pathlib import Path - data = json.loads(Path("artifact/validation.json").read_text()) - for key, env in (("version", "VERSION"), ("tag", "TAG"), ("source_sha", "SOURCE_SHA"), - ("framework_version", "FRAMEWORK_VERSION")): - if data.get(key) != os.environ[env]: + data = json.load(open("artifact/validation.json")) + for key, expected in (("version", "VERSION"), ("tag", "TAG"), ("source_sha", "SOURCE_SHA")): + if data.get(key) != os.environ[expected]: raise SystemExit(f"Validated artifact mismatch: {key}") if data.get("status") != "PASS": - raise SystemExit("The artifact did not pass validation.") + raise SystemExit("Artifact did not pass validation.") + PY + gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" > releases.json + python - <<'PY' + import json, os, re + version = tuple(int(x) for x in os.environ["VERSION"].split(".")) + version = (version + (0, 0, 0))[:3] + newer = False + for page in json.load(open("releases.json")): + for release in page: + match = re.fullmatch(r"content-v(\d+)\.(\d+)(?:\.(\d+))?", release["tag_name"]) + if match and not release["draft"] and not release["prerelease"]: + other = tuple(int(x or 0) for x in match.groups()) + if other == version and release["tag_name"] != os.environ["TAG"]: + raise SystemExit("Another tag identifies this content edition.") + if other > version: + newer = True + if newer and os.environ["ACTION"] == "create": + raise SystemExit("Cannot create an older edition after a newer release.") + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + print(f"latest={str(not newer).lower()}", file=out) PY cp artifact/site/gh-aw-book.pdf "$ASSET" { @@ -114,36 +167,68 @@ jobs: echo echo "---" echo - echo "๐Ÿ“– Read online: https://aw.isainative.dev/ ยท [Version history](https://aw.isainative.dev/versions.html)" + echo "Read online: https://aw.isainative.dev/ ยท [Version history](https://aw.isainative.dev/versions.html)" echo - echo "๐Ÿ“„ The single-file PDF for this version is attached below." + echo "The single-file PDF for this version is attached below." } > release-notes.md - - - name: Create or verify the tag without overwriting another source + - name: Create or verify immutable tag run: | - if [[ "$TAG_EXISTS" != "true" ]]; then - # Atomic creation fails if another publisher created the ref meanwhile. - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ - -f ref="refs/tags/$TAG" -f sha="$SOURCE_SHA" --silent - fi - ACTUAL_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" - if [[ "$ACTUAL_SHA" != "$SOURCE_SHA" ]]; then - echo "::error::Tag $TAG no longer points to the validated source; refusing to publish." - exit 1 + if [[ "$ACTION" != "skip" && "$TAG_EXISTS" != "true" ]]; then + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$TAG" -f sha="$SOURCE_SHA" --silent fi - - - name: Create the content release + test "$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" = "$SOURCE_SHA" + - name: Create draft with validated PDF if: env.ACTION == 'create' run: | - gh release create "$TAG" \ - "$ASSET#GitHub Agentic Workflows โ€” v$VERSION (PDF)" \ - --repo "$GITHUB_REPOSITORY" --verify-tag \ + gh release create "$TAG" "$ASSET#GitHub Agentic Workflows โ€” v$VERSION (PDF)" \ + --repo "$GITHUB_REPOSITORY" --verify-tag --draft \ --title "Content v$VERSION" --notes-file release-notes.md - - - name: Repair the missing PDF using only the exact-tag artifact + - name: Upload missing PDF without overwriting if: env.ACTION == 'upload' + run: gh release upload "$TAG" "$ASSET#GitHub Agentic Workflows โ€” v$VERSION (PDF)" --repo "$GITHUB_REPOSITORY" + - name: Publish completed draft + if: env.ACTION == 'create' || env.DRAFT == 'true' + env: + LATEST: ${{ steps.versions.outputs.latest }} + run: gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest="$LATEST" + + pages: + needs: [validate, release] + runs-on: ubuntu-latest + concurrency: + group: deploy-gh-pages + cancel-in-progress: false + permissions: + contents: write + pages: write + steps: + - name: Checkout trusted Pages helper + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: validated-release + path: artifact + - name: Publish exact validated site without gated PDF + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ inputs.source-sha }} run: | - # Do not clobber an asset uploaded concurrently by another publisher. - gh release upload "$TAG" \ - "$ASSET#GitHub Agentic Workflows โ€” v$VERSION (PDF)" \ - --repo "$GITHUB_REPOSITORY" + if [[ "$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)" != "$SOURCE_SHA" ]]; then + echo "Historical release recovery: leaving the current site unchanged." + exit 0 + fi + python scripts/publish_pages.py check + rm -f artifact/site/gh-aw-book.pdf + cd artifact/site + touch .nojekyll + git init -q -b gh-pages + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -q -m "Deploy $SOURCE_SHA" + DEPLOY_SHA="$(git rev-parse HEAD)" + git push -q --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" gh-pages + python "$GITHUB_WORKSPACE/scripts/publish_pages.py" build --commit "$DEPLOY_SHA" diff --git a/.github/workflows/validate-book.yml b/.github/workflows/validate-book.yml index 0d5839e..1026419 100644 --- a/.github/workflows/validate-book.yml +++ b/.github/workflows/validate-book.yml @@ -1,4 +1,4 @@ -name: Validate book +name: Check book update on: pull_request: @@ -17,6 +17,9 @@ on: artifact-name: type: string default: validated-book + require-content-change: + type: boolean + default: false outputs: source-sha: value: ${{ jobs.validate.outputs.source-sha }} @@ -30,6 +33,7 @@ permissions: jobs: validate: + name: Book release preflight runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -66,6 +70,7 @@ jobs: id: meta env: COMPARISON_BASE: ${{ inputs.comparison-base }} + REQUIRE_CONTENT_CHANGE: ${{ inputs.require-content-change }} run: | ARGS=() if [[ -n "$COMPARISON_BASE" ]]; then @@ -77,6 +82,8 @@ jobs: import json, os, subprocess from pathlib import Path data = json.loads(Path("validation.json").read_text()) + if os.environ.get("REQUIRE_CONTENT_CHANGE") == "true" and not data["changed"]: + raise SystemExit("No reader-visible content change since the published edition.") data["source_sha"] = subprocess.check_output( ["git", "-C", "source", "rev-parse", "HEAD"], text=True ).strip() @@ -124,11 +131,14 @@ jobs: APPINSIGHTS_CONNECTION_STRING: ${{ vars.APPINSIGHTS_CONNECTION_STRING }} run: python site/generate.py + - name: Install Chromium on the hosted runner + timeout-minutes: 5 + run: python -m playwright install chromium + - name: Build the PDF from the same source + timeout-minutes: 5 working-directory: source - run: | - python -m playwright install --with-deps chromium - python scripts/build_pdf.py + run: python -u scripts/build_pdf.py - name: Check generated versions and seal the validated artifact env: diff --git a/README.md b/README.md index bc9ca9b..785ce4d 100644 --- a/README.md +++ b/README.md @@ -355,15 +355,13 @@ historical editions. **GitHub Releases.** Each content version is published as a GitHub Release tagged `content-v` with the matching single-file PDF attached. This is automated by -[`.github/workflows/release-content.yml`](.github/workflows/release-content.yml): when a push to -`main` changes content, examples, release tooling, or the release/validation workflow, -the planner selects a new release, exact-tag repair, or idempotent no-op. New editions and -repairs must pass the reusable validation gate, which builds the exact site/PDF artifacts -the publishers consume without rebuilding. A complete existing release is a no-op, not a -new tooling edition. A missing-asset repair must use the original tag's -source, not the current branch's content. Manual dispatch is a separately authorized -recovery operation, not a way to bypass review. Legacy tags that predate the build/version -metadata need explicit historical recovery; the original v1.0 remains notes-only. +[`Release approved book`](.github/workflows/release-approved-book.yml) checks the PR's +exact revision, approval and content-only scope, then validates and merges it. It explicitly +chains publication of the exact merge commit; a direct push does not release a book. +The release is created as a draft, receives its validated PDF, and is then published. +The same validated build supplies the website, without its gated PDF. Existing complete +releases are no-ops; repairs use only the original tag. Legacy tags without release +metadata require explicit historical recovery. **To prepare a new version:** @@ -373,9 +371,22 @@ metadata need explicit historical recovery; the original v1.0 remains notes-only changes require renewed review. 3. Run `/release-content`: inspect real pending prose changes (including uncommitted files), add the changelog entry, bump the prose edition, and regenerate HTML/PDF. -4. Open a PR and pass `validate-book.yml`. A human reviews and merges it. -5. The gated deploy and release workflows publish the site and `content-v` with - its matching PDF. Confirm both outcomes before calling it published. +4. Open a same-repository content-only PR targeting `main` and pass **Book release preflight**. + If the PR has a different author, webmaxru approves the current head in GitHub review. + GitHub does not allow authors to approve their own PRs, so for a webmaxru-authored PR + comment `/release-book ` instead. A new commit needs a new + approval. Deleting the command withdraws it. +5. The controller rechecks approval, files and checks, merges with a head-SHA precondition, + validates the merged source and publishes the website and `content-v` PDF. + Check **Release approved book** for errors. To retry an interrupted publication, dispatch + that workflow on `main` with the PR number; it rechecks the original authorization. + Historical repairs keep their original tag and never replace the current website or + mark an older release as latest. + +Automatic merge rejects mixed infrastructure changes, including workflows, scripts, +dependency manifests, and presentation assets. Those PRs retain ordinary CI and must be +reviewed separately. Infrastructure-only site deployments require book content to match the +last published edition. `scripts/release_content.py` rejects metadata-only edition bumps, invalid versions, missing notes, and stale review evidence. The workflow gate compiles every standalone diff --git a/scripts/README.md b/scripts/README.md index fca4aa5..fa757d2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -30,7 +30,8 @@ maintenance does not rebuild architecture or scaffold over existing chapters. `-PromptPath` cannot be combined with `-Mode`/`-TargetVersion`. The headless launcher uses `copilot -p ... --allow-all-tools`, which grants broad tool access; use an interactive Copilot session instead when you want per-action approvals. Prompts stop at a prepared PR. -They do not grant permission to merge, dispatch publishing, or create a release. +They do not themselves merge, dispatch publishing, or create a release. The separately +configured approval controller acts only after an eligible content PR receives owner approval. ## Version and evidence contracts @@ -133,16 +134,33 @@ not committed sources. `validate-book.yml` runs on PRs and is reused by both publishing workflows. It checks the helpers, release/evidence contract, pinned whole-corpus compilation, and HTML/PDF generation. -The PR still needs human review/merge; these workflows do not configure branch protection. - -After merge, `deploy-pages.yml` publishes the current online edition and -`release-content.yml` publishes its matching tag, changelog notes, and versioned PDF. -Both consume the exact artifacts produced by validation instead of rebuilding afterward. -The release workflow also evaluates content/example/tooling pushes; a complete existing -release is a no-op, not a new tooling edition. Missing-asset repair must build the original tagged -source, never a different HEAD under an old edition label. Legacy tags without build/version -metadata require explicit historical recovery. A failed/empty existing upload is surfaced -for recovery, not mistaken for a complete PDF or silently overwritten. +The hosted Linux runner supplies browser system libraries; CI installs only Chromium, +without an APT refresh. Browser installation and PDF rendering are separate, bounded +steps so a download or renderer failure is reported distinctly. Other Linux environments +may still need Playwright's `--with-deps` setup. +The PR still needs explicit owner approval; these workflows do not configure branch protection. + +For a same-repository, content-only PR, webmaxru approves the current head with a GitHub +review. Since GitHub prevents self-approval, a webmaxru-authored PR instead requires an +owner comment `/release-book `. The trusted controller +rechecks the approval, scope and checks, validates the head, merges with a SHA precondition, +and explicitly chains a second validation and publication of the exact merge commit. +`release-content.yml` creates a draft with the PDF before publishing, then deploys the +same validated site without the gated PDF. A direct content push never releases. +Because a `GITHUB_TOKEN` push does not itself trigger a branch-source Pages build, +the publisher requests a legacy Pages build through GitHub's API and fails unless that +build reports the exact generated `gh-pages` commit. It needs `pages: write`; check the +workflow result if the repository or token cannot request that build. +To retry a failed merge/publication, dispatch `Release approved book` on `main` with the PR +number; authorization and source are rechecked. A complete release is a no-op and repair +never substitutes a newer source. A failed/empty asset requires manual recovery. Legacy +tags without build/version metadata require explicit historical recovery. Historical +repairs do not replace the current site or mark an older release as latest. + +The controller rejects workflow, script, dependency, presentation and other infrastructure +changes even if they accompany content; prepare those separately. `deploy-pages.yml` +continues to deploy infrastructure changes only while book content matches the last +published edition. ## Resume an interrupted update diff --git a/scripts/approved_book.py b/scripts/approved_book.py new file mode 100644 index 0000000..91589ac --- /dev/null +++ b/scripts/approved_book.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Verify exact-head approval and content-only scope using the GitHub API.""" + +import argparse +import base64 +import json +import os +import re +import sys +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +OWNER = "webmaxru" +SHA = re.compile(r"[0-9a-f]{40}\Z") +COMMAND = re.compile(r"/release-book ([0-9a-f]{40})\Z") + + +class PolicyError(ValueError): + pass + + +class GitHubError(RuntimeError): + pass + + +def allowed_path(path): + if path in {"content/VERSION", "content/FRAMEWORK_VERSION", "content/CHANGELOG.md", + "content/release-review.json", "content/toc.yml", "content/outline.md", + "content/playbook-brief.md"}: + return True + if path.startswith(("content/chapters/", "content/research/", "examples/")): + return True + if path in {"site/index.html", "site/book.html", "site/versions.html", "site/404.html", + "site/llms.txt", "site/llms-full.txt", "site/sitemap.xml", + "site/robots.txt", "site/site.webmanifest"}: + return True + return bool(re.fullmatch(r"site/chapters/[^/]+\.html", path)) + + +def approved(pr, reviews, comments): + head = pr["head"]["sha"] + if pr["user"]["login"].lower() == OWNER: + commands = [c for c in comments + if c.get("user", {}).get("login", "").lower() == OWNER + and COMMAND.fullmatch(c.get("body", "").strip())] + if not commands: + return False + latest = max(commands, key=lambda c: (c.get("updated_at") or c.get("created_at") or "", c["id"])) + return COMMAND.fullmatch(latest["body"].strip())[1] == head + own = [r for r in reviews if r.get("user", {}).get("login", "").lower() == OWNER] + if not own: + return False + latest = max(own, key=lambda r: (r.get("submitted_at") or "", r.get("id", 0))) + return latest.get("state") == "APPROVED" and latest.get("commit_id") == head + + +def eligibility(pr, files, reviews, comments, repository, merged=False): + head = pr.get("head", {}).get("sha", "") + base = pr.get("base", {}) + expected_state = "closed" if merged else "open" + head_repo = pr.get("head", {}).get("repo") or {} + if (pr.get("state") != expected_state or pr.get("draft") or base.get("ref") != "main" + or head_repo.get("full_name", "").lower() != repository.lower() + or not SHA.fullmatch(head) or (merged and not pr.get("merged"))): + raise PolicyError("PR must be a non-draft same-repository content PR targeting main.") + if len(files) != pr.get("changed_files") or not files: + raise PolicyError("Changed file list is incomplete or empty.") + paths = [] + for file in files: + paths.append(file["filename"]) + if file.get("previous_filename"): + paths.append(file["previous_filename"]) + if not all(allowed_path(path) for path in paths): + raise PolicyError("PR contains paths outside the content-only allowlist.") + if not any(path.startswith("content/chapters/") or path == "content/toc.yml" for path in paths): + raise PolicyError("PR does not change a chapter or TOC.") + if not approved(pr, reviews, comments): + raise PolicyError("Owner approval for the current head is missing.") + return {"number": pr["number"], "head": head, "base": base["sha"]} + + +def checks_pass(checks, statuses, require_preflight=True): + latest = {} + for check in checks: + name = check.get("name") + app = check.get("app") or {} + key = (name, app.get("id") or app.get("slug")) + if key not in latest or check.get("id", 0) > latest[key].get("id", 0): + latest[key] = check + preflights = [check for (name, _), check in latest.items() + if name == "Book release preflight" + and (check.get("app") or {}).get("slug") == "github-actions"] + preflight = max(preflights, key=lambda check: check.get("id", 0)) if preflights else None + if require_preflight and ( + not preflight or preflight.get("status") != "completed" + or preflight.get("conclusion") != "success" + ): + raise PolicyError("Book release preflight has not succeeded on the PR head.") + if any(c.get("status") != "completed" or c.get("conclusion") not in {"success", "neutral", "skipped"} + for c in latest.values()): + raise PolicyError("A check on the PR head is pending or unsuccessful.") + latest_status = {} + for status in statuses: + latest_status.setdefault(status["context"], status) + if any(s.get("state") != "success" for s in latest_status.values()): + raise PolicyError("A commit status on the PR head is pending or unsuccessful.") + + +class GitHub: + def __init__(self, repository, token): + self.repo = repository + self.token = token + + def request(self, path, method="GET", data=None): + body = json.dumps(data).encode() if data is not None else None + request = Request("https://api.github.com" + path, data=body, method=method, headers={ + "Authorization": "Bearer " + self.token, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }) + try: + with urlopen(request, timeout=30) as response: + return json.load(response) + except (HTTPError, URLError) as exc: + raise GitHubError(f"GitHub API {method} {path} failed: {exc}") from exc + + def get(self, suffix): + return self.request(f"/repos/{self.repo}/{suffix}") + + def pages(self, suffix, key=None): + result = [] + for page in range(1, 101): + separator = "&" if "?" in suffix else "?" + response = self.get(f"{suffix}{separator}{urlencode({'per_page': 100, 'page': page})}") + items = response[key] if key else response + result.extend(items) + if len(items) < 100: + return result + raise GitHubError(f"Pagination limit exceeded for {suffix}.") + + def inspect(self, number, merged=False): + pr = self.get(f"pulls/{number}") + result = eligibility(pr, self.pages(f"pulls/{number}/files"), + self.pages(f"pulls/{number}/reviews"), + self.pages(f"issues/{number}/comments"), self.repo, merged) + checks_pass(self.pages(f"commits/{result['head']}/check-runs", "check_runs"), + self.pages(f"commits/{result['head']}/statuses")) + if not merged: + merge_ref = pr.get("merge_commit_sha") + if not merge_ref or not SHA.fullmatch(merge_ref): + raise PolicyError("GitHub has not produced a PR merge commit.") + parents = [parent["sha"] for parent in self.get(f"commits/{merge_ref}").get("parents", [])] + if parents != [result["base"], result["head"]]: + raise PolicyError("PR test merge does not match the current base and head.") + checks_pass(self.pages(f"commits/{merge_ref}/check-runs", "check_runs"), + self.pages(f"commits/{merge_ref}/statuses"), require_preflight=False) + if merged: + result["merge_sha"] = pr["merge_commit_sha"] + return result + + def previous_edition(self, head, merged=False): + blob = self.get(f"contents/content/VERSION?ref={head}") + if blob.get("encoding") != "base64": + raise PolicyError("Unable to read the proposed content edition.") + try: + version = base64.b64decode(blob["content"], validate=False).decode("utf-8").strip() + except (ValueError, UnicodeError) as exc: + raise PolicyError("Invalid content edition response.") from exc + pattern = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?\Z") + match = pattern.fullmatch(version) + if not match: + raise PolicyError("Invalid content edition.") + key = tuple(int(part or 0) for part in match.groups()) + editions = [] + drafts = [] + for release in self.pages("releases"): + tag = release.get("tag_name", "") + old = pattern.fullmatch(tag.removeprefix("content-v")) if tag.startswith("content-v") else None + if old and not release.get("prerelease"): + item = (tuple(int(part or 0) for part in old.groups()), tag) + (drafts if release.get("draft") else editions).append(item) + if not merged and any(old >= key for old, _ in editions): + raise PolicyError("Proposed edition is not newer than the latest published edition.") + if merged and any(old > key for old, _ in editions) and (key, f"content-v{version}") not in editions + drafts: + raise PolicyError("Cannot create an older edition after a newer publication.") + previous = sorted((old, tag) for old, tag in editions if old < key) + if not previous: + raise PolicyError("No previous published content edition is available.") + if len(previous) > 1 and previous[-1][0] == previous[-2][0]: + raise PolicyError("Ambiguous previous published content edition.") + return previous[-1][1] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("select", "merge", "verify-merged")) + parser.add_argument("--number", type=int) + parser.add_argument("--head") + parser.add_argument("--base") + parser.add_argument("--comparison") + parser.add_argument("--merge-sha") + args = parser.parse_args() + try: + api = GitHub(os.environ["GITHUB_REPOSITORY"], os.environ["GH_TOKEN"]) + if args.action == "select": + numbers = [args.number] if args.number else [ + pr["number"] for pr in api.pages("pulls?state=open&base=main") + ] + selected = None + for number in numbers: + try: + pr = api.get(f"pulls/{number}") + if args.number and pr.get("merged"): + selected = api.inspect(number, merged=True) + parents = api.get(f"commits/{selected['merge_sha']}").get("parents", []) + if len(parents) != 2 or parents[1].get("sha") != selected["head"]: + raise PolicyError("Recovery requires a merge commit containing the approved head.") + selected["base"] = parents[0]["sha"] + else: + selected = api.inspect(number) + selected["comparison"] = api.previous_edition(selected["head"], bool(selected.get("merge_sha"))) + break + except PolicyError as exc: + if args.number: + raise + print(f"PR #{number}: {exc}", file=sys.stderr) + print(json.dumps(selected or {})) + elif args.action == "merge": + if (not args.number or not SHA.fullmatch(args.head or "") or not SHA.fullmatch(args.base or "") + or not args.comparison): + raise PolicyError("Merge requires PR number, exact head, base and edition baseline.") + current = api.inspect(args.number) + if current["head"] != args.head or current["base"] != args.base: + raise PolicyError("PR head or base changed after preflight.") + if api.previous_edition(current["head"]) != args.comparison: + raise PolicyError("Published edition baseline changed after preflight.") + response = api.request(f"/repos/{api.repo}/pulls/{args.number}/merge", "PUT", + {"sha": args.head, "merge_method": "merge"}) + if response.get("merged") is not True or not SHA.fullmatch(response.get("sha", "")): + raise PolicyError("GitHub did not confirm the merge.") + commit = api.get(f"commits/{response['sha']}") + if not commit.get("parents") or commit["parents"][0].get("sha") != args.base: + raise PolicyError("Merge base changed concurrently; refusing publication.") + print(json.dumps({"merge_sha": response["sha"], **current})) + else: + current = api.inspect(args.number, merged=True) + if current["merge_sha"] != args.merge_sha or not SHA.fullmatch(args.merge_sha or ""): + raise PolicyError("PR merge commit does not match the requested source.") + if not SHA.fullmatch(args.base or ""): + raise PolicyError("Expected merge base is required.") + commit = api.get(f"commits/{args.merge_sha}") + parents = [parent["sha"] for parent in commit.get("parents", [])] + if parents != [args.base, current["head"]]: + raise PolicyError("Merge parents do not match the approved head and base.") + print(json.dumps(current)) + except (KeyError, PolicyError, GitHubError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_pages.py b/scripts/publish_pages.py new file mode 100644 index 0000000..536accd --- /dev/null +++ b/scripts/publish_pages.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Request and verify a legacy Pages build for an exact gh-pages commit.""" + +import argparse +import os +import re +import sys +import time +from urllib.parse import urlsplit + +from approved_book import GitHub, GitHubError, PolicyError, SHA + + +def check_source(api): + pages = api.get("pages") + if pages.get("build_type") != "legacy" or pages.get("source") != {"branch": "gh-pages", "path": "/"}: + raise PolicyError("Pages must use the legacy gh-pages root source.") + + +def request_build(api, commit, *, sleep=time.sleep, attempts=60): + if not SHA.fullmatch(commit): + raise PolicyError("An exact gh-pages commit SHA is required.") + check_source(api) + response = api.request(f"/repos/{api.repo}/pages/builds", "POST") + url = urlsplit(response.get("url", "")) + expected_path = f"/repos/{api.repo}/pages/builds/" + if (url.scheme != "https" or url.netloc != "api.github.com" + or not re.fullmatch(re.escape(expected_path) + r"\d+", url.path) + or url.query or url.fragment): + raise PolicyError("Pages returned an unexpected build URL.") + suffix = url.path.removeprefix(f"/repos/{api.repo}/") + for attempt in range(attempts): + build = api.get(suffix) + if build.get("status") == "built": + if build.get("commit") != commit: + raise PolicyError("Pages built a different commit than the validated site.") + return build + if build.get("status") in {"errored", "failed", "cancelled"}: + raise PolicyError(f"Pages build failed: {(build.get('error') or {}).get('message')}") + if attempt + 1 < attempts: + sleep(10) + raise PolicyError("Pages build did not complete before the timeout.") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("check", "build")) + parser.add_argument("--commit") + args = parser.parse_args() + try: + api = GitHub(os.environ["GITHUB_REPOSITORY"], os.environ["GH_TOKEN"]) + if args.action == "check": + check_source(api) + print("Legacy Pages source verified.") + else: + build = request_build(api, args.commit or "") + print(f"Pages built commit {build['commit']}.") + except (KeyError, PolicyError, GitHubError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_content.py b/scripts/release_content.py index 8d79abb..a8c3a91 100644 --- a/scripts/release_content.py +++ b/scripts/release_content.py @@ -469,7 +469,7 @@ def github_release(repository: str, tag: str) -> dict[str, Any] | None: def release_plan( root: Path, repository: str, base: str | None = None, - env: Mapping[str, str] | None = None, + env: Mapping[str, str] | None = None, expected_source: str | None = None, ) -> dict[str, Any]: """Read-only publication planning; existing tags always select their own source.""" if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): @@ -477,10 +477,21 @@ def release_plan( version = content_version.read_version(root / "content" / "VERSION") tag = content_version.tag_for(version) asset = f"gh-aw-book-v{version}.pdf" + tag_exists = tag in git(root, "tag", "--list", tag).decode().splitlines() + if expected_source is not None: + expected = resolve_ref(root, expected_source) + if expected != resolve_ref(root, "HEAD"): + raise ReleaseError("Expected source does not match the checked-out source.") + if tag_exists and resolve_ref(root, tag) != expected: + raise ReleaseError(f"Existing tag {tag} points to a different source.") metadata = github_release(repository, tag) if metadata is not None: if metadata.get("tag_name") != tag or not isinstance(metadata.get("assets"), list): raise ReleaseError("The release API response does not match the requested tag/assets.") + if metadata.get("prerelease"): + raise ReleaseError(f"Release {tag} is a prerelease; explicit recovery is required.") + if expected_source is not None and not tag_exists: + raise ReleaseError(f"Release {tag} exists but its tag is unavailable.") matching = [item for item in metadata["assets"] if item.get("name") == asset] if matching: if ( @@ -489,19 +500,23 @@ def release_plan( and type(matching[0].get("size")) is int and matching[0]["size"] > 0 ): - return {"action": "skip", "version": version, "tag": tag, "asset": asset} - details = "; ".join( - f"id={item.get('id')!r}, state={item.get('state')!r}, size={item.get('size')!r}" - for item in matching - ) - raise ReleaseError( - f"Release {tag} has an incomplete or ambiguous upload for {asset} ({details}). " - "A completed asset must have state='uploaded' and a positive integer size. " - f"Inspect https://github.com/{repository}/releases/tag/{tag}; have a maintainer " - "restore the correct tagged PDF or remove the incomplete asset before retrying " - "exact-tag repair. No asset was deleted or overwritten." - ) - tag_exists = tag in git(root, "tag", "--list", tag).decode().splitlines() + if not metadata.get("draft"): + return {"action": "skip", "version": version, "tag": tag, "asset": asset} + has_asset = True + else: + details = "; ".join( + f"id={item.get('id')!r}, state={item.get('state')!r}, size={item.get('size')!r}" + for item in matching + ) + raise ReleaseError( + f"Release {tag} has an incomplete or ambiguous upload for {asset} ({details}). " + "A completed asset must have state='uploaded' and a positive integer size. " + f"Inspect https://github.com/{repository}/releases/tag/{tag}; have a maintainer " + "restore the correct tagged PDF or remove the incomplete asset before retrying " + "exact-tag repair. No asset was deleted or overwritten." + ) + else: + has_asset = False if metadata is not None and not tag_exists: raise ReleaseError(f"Release {tag} exists but its tag is unavailable; fetch tags before repair.") source = resolve_ref(root, tag if tag_exists else "HEAD") @@ -525,7 +540,8 @@ def release_plan( ) comparison, reason = (tag, "existing-tag-replay") if tag_exists else select_base(root, base, env) return { - "action": "upload" if metadata is not None else "create", + "action": ("publish" if metadata is not None and has_asset else + "upload" if metadata is not None else "create"), "version": version, "tag": tag, "asset": asset, @@ -534,6 +550,7 @@ def release_plan( "comparison_base": comparison, "base_reason": reason, "tag_exists": tag_exists, + "draft": bool(metadata and metadata.get("draft")), } @@ -615,6 +632,7 @@ def main(argv: list[str] | None = None) -> int: plan_parser = commands.add_parser("release-plan", help="Read-only create/repair/skip source selection.") plan_parser.add_argument("--repo", required=True) plan_parser.add_argument("--base") + plan_parser.add_argument("--expected-source") review_parser = commands.add_parser("record-review", help="Record an actual editorial verdict.") review_parser.add_argument( "--report", required=True, @@ -637,7 +655,7 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "check-generated": print(_json(check_generated(root)), end="") elif args.command == "release-plan": - print(_json(release_plan(root, args.repo, args.base)), end="") + print(_json(release_plan(root, args.repo, args.base, expected_source=args.expected_source)), end="") elif args.command == "record-review": record = _json(review_record(root, args.report, args.verdict)) (root / REVIEW_PATH).write_text(record, encoding="utf-8", newline="\n") diff --git a/scripts/tests/test_approved_book.py b/scripts/tests/test_approved_book.py new file mode 100644 index 0000000..ee2dcc0 --- /dev/null +++ b/scripts/tests/test_approved_book.py @@ -0,0 +1,267 @@ +import contextlib +import base64 +import io +import json +import os +import subprocess +import sys +import unittest +from copy import deepcopy +from unittest.mock import patch + +from helpers import ROOT + +import approved_book as policy + +HEAD = "a" * 40 +BASE = "b" * 40 +MERGE = "c" * 40 +PR_MERGE = "d" * 40 +REPO = "webmaxru/github-agentic-workflows-book" + + +class ApprovalTests(unittest.TestCase): + def setUp(self): + self.pr = { + "number": 7, "state": "open", "draft": False, "changed_files": 2, + "user": {"login": "webmaxru"}, + "head": {"sha": HEAD, "repo": {"full_name": REPO}}, + "base": {"sha": BASE, "ref": "main"}, "merge_commit_sha": PR_MERGE, + } + self.files = [{"filename": "content/chapters/one.html"}, + {"filename": "content/VERSION"}] + self.comments = [{"id": 1, "user": {"login": "webmaxru"}, + "body": "/release-book " + HEAD}] + self.review = [{"id": 1, "user": {"login": "webmaxru"}, "state": "APPROVED", + "commit_id": HEAD, "submitted_at": "2026-01-01T00:00:00Z"}] + self.check = {"id": 1, "name": "Book release preflight", "status": "completed", + "conclusion": "success", "app": {"slug": "github-actions"}} + + def eligible(self): + return policy.eligibility(self.pr, self.files, self.review, self.comments, REPO) + + def test_owner_command_must_be_exact_current_and_from_owner(self): + self.assertEqual(self.eligible()["head"], HEAD) + for body in ("/release-book " + "b" * 40, "/release-book " + HEAD[:12], + "please /release-book " + HEAD): + self.comments[0]["body"] = body + with self.subTest(body=body), self.assertRaises(policy.PolicyError): + self.eligible() + self.comments[0]["body"] = "/release-book " + HEAD + self.comments[0]["user"]["login"] = "other" + with self.assertRaises(policy.PolicyError): + self.eligible() + + def test_new_owner_command_supersedes_old_and_owner_cannot_self_review(self): + self.comments.append({"id": 2, "user": {"login": "webmaxru"}, + "body": "/release-book " + "e" * 40}) + with self.assertRaises(policy.PolicyError): + self.eligible() + self.comments.clear() + with self.assertRaises(policy.PolicyError): + self.eligible() + + def test_non_author_approval_is_latest_and_bound_to_head(self): + self.pr["user"]["login"] = "book-bot" + self.assertEqual(self.eligible()["head"], HEAD) + self.review.append({**self.review[0], "id": 2, "state": "DISMISSED", + "submitted_at": "2026-01-02T00:00:00Z"}) + with self.assertRaises(policy.PolicyError): + self.eligible() + self.review[-1]["state"] = "APPROVED" + self.review[-1]["commit_id"] = "f" * 40 + with self.assertRaises(policy.PolicyError): + self.eligible() + + def test_rejects_mixed_infra_renames_forks_drafts_and_incomplete_pages(self): + for filename in (".github/workflows/release.yml", "scripts/build_pdf.py", + "site/generate.py", "site/assets/app.js", "package.json"): + self.files[1]["filename"] = filename + with self.subTest(filename=filename), self.assertRaises(policy.PolicyError): + self.eligible() + self.files[1] = {"filename": "content/VERSION", "previous_filename": "scripts/old.py"} + with self.assertRaises(policy.PolicyError): + self.eligible() + self.files[1] = {"filename": "content/VERSION"} + for key in ("draft", "fork", "partial", "wrong_base"): + pr = deepcopy(self.pr) + files = deepcopy(self.files) + if key == "draft": + pr["draft"] = True + elif key == "fork": + pr["head"]["repo"]["full_name"] = "other/book" + elif key == "partial": + files.pop() + else: + pr["base"]["ref"] = "other" + with self.subTest(key=key), self.assertRaises(policy.PolicyError): + policy.eligibility(pr, files, self.review, self.comments, REPO) + + def test_checks_must_be_completed_successful_and_from_actions(self): + policy.checks_pass([self.check], []) + for change in ({"conclusion": "failure"}, {"status": "in_progress"}, + {"app": {"slug": "other"}}): + check = {**self.check, **change} + with self.subTest(change=change), self.assertRaises(policy.PolicyError): + policy.checks_pass([check], []) + with self.assertRaises(policy.PolicyError): + policy.checks_pass([self.check, {"id": 2, "name": "security", + "status": "completed", "conclusion": "failure"}], []) + with self.assertRaises(policy.PolicyError): + policy.checks_pass([self.check, + {"id": 2, "name": "security", "status": "completed", + "conclusion": "failure", "app": {"id": 1}}, + {"id": 3, "name": "security", "status": "completed", + "conclusion": "success", "app": {"id": 2}}], []) + with self.assertRaises(policy.PolicyError): + policy.checks_pass([self.check], [{"context": "ci", "state": "pending"}]) + + def test_published_baseline_and_recovery(self): + api = policy.GitHub(REPO, "fixture") + blob = {"encoding": "base64", "content": base64.b64encode(b"1.2\n").decode()} + releases = [ + {"tag_name": "content-v1.0", "draft": False, "prerelease": False}, + {"tag_name": "content-v1.1", "draft": False, "prerelease": False}, + {"tag_name": "content-v1.3", "draft": True, "prerelease": False}, + ] + with patch.object(api, "get", return_value=blob), patch.object(api, "pages", return_value=releases): + self.assertEqual(api.previous_edition(HEAD), "content-v1.1") + releases.append({"tag_name": "content-v1.2", "draft": False, "prerelease": False}) + with self.assertRaises(policy.PolicyError): + api.previous_edition(HEAD) + self.assertEqual(api.previous_edition(HEAD, merged=True), "content-v1.1") + releases.append({"tag_name": "content-v1.3", "draft": False, "prerelease": False}) + self.assertEqual(api.previous_edition(HEAD, merged=True), "content-v1.1") + releases[-2]["tag_name"] = "content-v1.2.1" + with self.assertRaises(policy.PolicyError): + api.previous_edition(HEAD, merged=True) + + def test_pagination_fetches_every_page(self): + api = policy.GitHub(REPO, "fixture") + with patch.object(api, "get", side_effect=[list(range(100)), [100]]) as get: + self.assertEqual(len(api.pages("pulls/7/files")), 101) + self.assertEqual(get.call_count, 2) + + def test_cli_select_and_merge_enforce_head_and_base(self): + writes = [] + pr = self.pr + files = self.files + comments = self.comments + check = self.check + + test_merge_parents = [BASE, HEAD] + + def request(api, path, method="GET", data=None): + if method == "PUT": + writes.append(data) + return {"merged": True, "sha": MERGE} + route = path.split("?", 1)[0].split(f"/repos/{REPO}/", 1)[1] + if route == "pulls/7": + return pr + if route == "pulls/7/files": + return files + if route == "pulls/7/reviews": + return [] + if route == "issues/7/comments": + return comments + if route == "contents/content/VERSION": + return {"encoding": "base64", "content": base64.b64encode(b"1.2\n").decode()} + if route == "releases": + return [{"tag_name": "content-v1.1", "draft": False, "prerelease": False}] + if route.endswith("/check-runs"): + return {"check_runs": [check] if HEAD in route else []} + if route.endswith("/statuses"): + return [] + if route == f"commits/{MERGE}": + return {"parents": [{"sha": BASE}, {"sha": HEAD}]} + if route == f"commits/{PR_MERGE}": + return {"parents": [{"sha": sha} for sha in test_merge_parents]} + self.fail(f"unexpected route {route}") + + def run(*args): + out = io.StringIO() + with patch.object(sys, "argv", ["approved_book.py", *args]), \ + patch.dict(os.environ, {"GITHUB_REPOSITORY": REPO, "GH_TOKEN": "fixture"}), \ + patch.object(policy.GitHub, "request", request), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + code = policy.main() + return code, out.getvalue() + + code, output = run("select", "--number", "7") + self.assertEqual(code, 0) + self.assertEqual(json.loads(output)["head"], HEAD) + test_merge_parents[0] = "f" * 40 + self.assertEqual(run("select", "--number", "7")[0], 1) + test_merge_parents[0] = BASE + self.assertEqual(run("merge", "--number", "7", "--head", "f" * 40, "--base", BASE, + "--comparison", "content-v1.1")[0], 1) + self.assertFalse(writes) + self.assertEqual(run("merge", "--number", "7", "--head", HEAD, "--base", BASE, + "--comparison", "content-v1.0")[0], 1) + self.assertFalse(writes) + code, output = run("merge", "--number", "7", "--head", HEAD, "--base", BASE, + "--comparison", "content-v1.1") + self.assertEqual(code, 0) + self.assertEqual(json.loads(output)["merge_sha"], MERGE) + self.assertEqual(writes, [{"sha": HEAD, "merge_method": "merge"}]) + pr.update(state="closed", merged=True, merge_commit_sha=MERGE) + code, output = run("select", "--number", "7") + self.assertEqual(code, 0) + self.assertEqual(json.loads(output)["merge_sha"], MERGE) + self.assertEqual(run("verify-merged", "--number", "7", "--merge-sha", MERGE, + "--base", BASE)[0], 0) + self.assertEqual(run("verify-merged", "--number", "7", "--merge-sha", "e" * 40, + "--base", BASE)[0], 1) + comments.clear() + self.assertEqual(run("verify-merged", "--number", "7", "--merge-sha", MERGE, + "--base", BASE)[0], 1) + self.assertEqual(len(writes), 1) + + def test_select_script_in_separate_process_with_api_fixtures(self): + fixture = { + "pr": self.pr, "files": self.files, "comments": self.comments, + "checks": [self.check], "head": HEAD, "base": BASE, "repo": REPO, + } + bootstrap = r""" +import io, json, os, runpy, sys, urllib.parse, urllib.request +data = json.loads(os.environ["FIXTURE"]) +class Response(io.BytesIO): + def __enter__(self): return self + def __exit__(self, *args): self.close() +def fake(request, timeout=30): + path = urllib.parse.urlsplit(request.full_url).path.split("/repos/" + data["repo"] + "/", 1)[1] + routes = { + "pulls/7": data["pr"], "pulls/7/files": data["files"], + "pulls/7/reviews": [], "issues/7/comments": data["comments"], + "contents/content/VERSION": {"encoding": "base64", "content": "MS4yCg=="}, + "releases": [{"tag_name": "content-v1.1", "draft": False, "prerelease": False}], + } + if path.endswith("/check-runs"): + result = {"check_runs": data["checks"] if data["head"] in path else []} + elif path.endswith("/statuses"): + result = [] + elif path.startswith("commits/"): + result = {"parents": [{"sha": data["base"]}, {"sha": data["head"]}]} + else: + result = routes[path] + return Response(json.dumps(result).encode()) +urllib.request.urlopen = fake +sys.argv = [sys.argv[1], "select", "--number", "7"] +runpy.run_path(sys.argv[0], run_name="__main__") +""" + env = dict(os.environ, GITHUB_REPOSITORY=REPO, GH_TOKEN="fixture", FIXTURE=json.dumps(fixture)) + script = str(ROOT / "approved_book.py") + result = subprocess.run([sys.executable, "-c", bootstrap, script], env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["head"], HEAD) + fixture["comments"][0]["body"] = "/release-book " + "f" * 40 + env["FIXTURE"] = json.dumps(fixture) + result = subprocess.run([sys.executable, "-c", bootstrap, script], env=env, + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("approval", result.stderr.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_publish_pages.py b/scripts/tests/test_publish_pages.py new file mode 100644 index 0000000..553a135 --- /dev/null +++ b/scripts/tests/test_publish_pages.py @@ -0,0 +1,47 @@ +import unittest +from unittest.mock import patch + +from helpers import ROOT + +import approved_book +import publish_pages + +REPO = "webmaxru/github-agentic-workflows-book" +COMMIT = "a" * 40 + + +class PagesTests(unittest.TestCase): + def test_build_requests_and_checks_exact_legacy_commit(self): + api = approved_book.GitHub(REPO, "fixture") + source = {"build_type": "legacy", "source": {"branch": "gh-pages", "path": "/"}} + url = f"https://api.github.com/repos/{REPO}/pages/builds/42" + with patch.object(api, "request", return_value={"url": url}) as post, \ + patch.object(api, "get", side_effect=[source, {"status": "building"}, + {"status": "built", "commit": COMMIT}]), \ + patch.object(publish_pages.time, "sleep"): + result = publish_pages.request_build(api, COMMIT, sleep=lambda _: None) + self.assertEqual(result["commit"], COMMIT) + post.assert_called_once_with(f"/repos/{REPO}/pages/builds", "POST") + + def test_wrong_source_url_commit_failure_and_timeout_are_errors(self): + api = approved_book.GitHub(REPO, "fixture") + source = {"build_type": "legacy", "source": {"branch": "gh-pages", "path": "/"}} + with patch.object(api, "get", return_value={"build_type": "workflow"}): + with self.assertRaises(approved_book.PolicyError): + publish_pages.request_build(api, COMMIT) + with patch.object(api, "get", return_value=source), \ + patch.object(api, "request", return_value={"url": "https://evil.example/x"}): + with self.assertRaises(approved_book.PolicyError): + publish_pages.request_build(api, COMMIT) + url = f"https://api.github.com/repos/{REPO}/pages/builds/42" + for build in ({"status": "built", "commit": "b" * 40}, + {"status": "errored", "error": {"message": "failed"}}, + {"status": "building"}): + with self.subTest(build=build), patch.object(api, "request", return_value={"url": url}), \ + patch.object(api, "get", side_effect=[source, build]): + with self.assertRaises(approved_book.PolicyError): + publish_pages.request_build(api, COMMIT, attempts=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_release_plan.py b/scripts/tests/test_release_plan.py index 52adde7..a9068d6 100644 --- a/scripts/tests/test_release_plan.py +++ b/scripts/tests/test_release_plan.py @@ -169,6 +169,22 @@ def test_tag_version_mismatch_is_rejected(self, api): with self.assertRaisesRegex(release.ReleaseError, "selected source contains 1.0"): release.release_plan(self.root, "owner/book", env={}) + @patch.object(release, "github_release") + def test_expected_source_rejects_tag_reuse_and_complete_draft_resumes(self, api): + self.accept() + self.commit() + head = self.git("rev-parse", "HEAD") + api.return_value = self.metadata(["gh-aw-book-v1.1.pdf"]) + with self.assertRaisesRegex(release.ReleaseError, "different source"): + release.release_plan(self.root, "owner/book", env={}, expected_source=head) + self.git("tag", "--force", "content-v1.1") + api.return_value["draft"] = True + plan = release.release_plan(self.root, "owner/book", env={}, expected_source=head) + self.assertEqual(plan["action"], "publish") + self.assertTrue(plan["draft"]) + api.return_value["draft"] = False + self.assertEqual(release.release_plan(self.root, "owner/book", env={}, expected_source=head)["action"], "skip") + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_workflows.py b/scripts/tests/test_workflows.py index c96a018..5da8873 100644 --- a/scripts/tests/test_workflows.py +++ b/scripts/tests/test_workflows.py @@ -28,30 +28,66 @@ def test_validation_is_reusable_and_runs_on_pull_requests(self): self.assertNotIn("record-review", commands) self.assertIn("APPINSIGHTS_CONNECTION_STRING", str(steps)) + def test_browser_install_and_pdf_render_have_separate_bounds(self): + steps = self.workflow("validate-book.yml")["jobs"]["validate"]["steps"] + install = next(step for step in steps if "playwright install" in step.get("run", "")) + render = next(step for step in steps if "scripts/build_pdf.py" in step.get("run", "")) + self.assertNotIn("--with-deps", install["run"]) + self.assertEqual(install["timeout-minutes"], "5") + self.assertEqual(render["timeout-minutes"], "5") + self.assertEqual(render["working-directory"], "source") + def test_both_publishers_require_validation_and_consume_its_artifact(self): for name, publisher in (("deploy-pages.yml", "deploy"), ("release-content.yml", "release")): with self.subTest(workflow=name): workflow = self.workflow(name) - self.assertEqual(workflow["permissions"], {"contents": "read"}) + self.assertEqual(workflow["permissions"]["contents"], "read") jobs = workflow["jobs"] self.assertEqual(jobs["validate"]["uses"], "./.github/workflows/validate-book.yml") self.assertIn("validate", jobs[publisher]["needs"]) - self.assertEqual(jobs[publisher]["permissions"], {"contents": "write"}) + self.assertEqual(jobs[publisher]["permissions"]["contents"], "write") steps = jobs[publisher]["steps"] self.assertTrue(any(step.get("uses", "").startswith("actions/download-artifact@") for step in steps)) self.assertFalse(any("scripts/build_pdf.py" in step.get("run", "") for step in steps)) - self.assertIn("examples/**", workflow["on"]["push"]["paths"]) + self.assertNotIn("push", self.workflow("release-content.yml")["on"]) + self.assertNotIn("workflow_dispatch", self.workflow("release-content.yml")["on"]) def test_release_repairs_use_explicit_source_and_never_clobber(self): jobs = self.workflow("release-content.yml")["jobs"] - self.assertIn("source_sha", jobs["validate"]["with"]["source-ref"]) - self.assertIn("comparison_base", jobs["validate"]["with"]["comparison-base"]) + self.assertIn("source-sha", jobs["validate"]["with"]["source-ref"]) + self.assertIn("comparison-base", jobs["validate"]["with"]["comparison-base"]) commands = "\n".join(step.get("run", "") for step in jobs["release"]["steps"]) self.assertIn("--verify-tag", commands) - self.assertIn("ACTUAL_SHA", commands) + self.assertIn("--draft", commands) + self.assertIn("--draft=false", commands) self.assertNotIn("--clobber", commands) self.assertNotIn("--target", commands) + def test_controller_is_default_branch_trusted_and_chains_publication(self): + signal = self.workflow("book-review-signal.yml") + self.assertEqual(signal["permissions"], {}) + self.assertIn("pull_request_review", signal["on"]) + controller = self.workflow("release-approved-book.yml") + self.assertIn("workflow_run", controller["on"]) + self.assertIn("issue_comment", controller["on"]) + self.assertIn("refs/heads/main", controller["jobs"]["select"]["if"]) + self.assertIn("preflight", controller["jobs"]["merge"]["needs"]) + self.assertEqual(controller["jobs"]["preflight"]["with"]["require-content-change"], "true") + self.assertEqual(controller["jobs"]["publish"]["uses"], "./.github/workflows/release-content.yml") + self.assertIn("merge-sha", controller["jobs"]["publish"]["with"]["source-sha"]) + release = self.workflow("release-content.yml") + self.assertEqual(release["jobs"]["validate"]["with"]["require-content-change"], "true") + + def test_legacy_pages_build_is_explicit_and_checks_generated_commit(self): + for workflow, job in (("deploy-pages.yml", "deploy"), ("release-content.yml", "pages")): + with self.subTest(workflow=workflow): + settings = self.workflow(workflow)["jobs"][job] + self.assertEqual(settings["permissions"]["pages"], "write") + commands = "\n".join(step.get("run", "") for step in settings["steps"]) + self.assertIn("publish_pages.py", commands) + self.assertIn('build --commit "$DEPLOY_SHA"', commands) + self.assertIn("gh-aw-book.pdf", commands) + if __name__ == "__main__": unittest.main()