Sync static-site docs #5
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 static-site docs | |
| on: | |
| schedule: | |
| - cron: '0 9 * * 3' # Weekly on Wednesday | |
| workflow_dispatch: | |
| inputs: | |
| ref: | |
| description: >- | |
| Ref to sync, for a docs fix that has shipped upstream but not been | |
| released. Any commit-ish the clone can resolve. Leave empty to sync | |
| the latest release, which is what the weekly run does. | |
| required: false | |
| type: string | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: Create GitHub App Token | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 | |
| id: app-token | |
| with: | |
| client-id: ${{ vars.PR_AUTOMATION_BOT_PUBLIC_CLIENT_ID }} | |
| private-key: ${{ secrets.PR_AUTOMATION_BOT_PUBLIC_PRIVATE_KEY }} | |
| # certified-assets is not a submodule: the build resolves no file from it. | |
| # A blobless clone is only needed to reason about refs (is the latest | |
| # release already contained in the pin, did docs/ change), which the | |
| # contents API cannot answer. | |
| - name: Clone certified-assets | |
| run: git clone --filter=blob:none --no-checkout https://github.com/dfinity/certified-assets.git /tmp/certified-assets | |
| - name: Resolve the ref to sync | |
| id: check | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| # A dispatch input reaches the shell as data, never as script. | |
| INPUT_REF: ${{ inputs.ref }} | |
| run: | | |
| PIN=$(node -p "require('./.sources/upstream.json').synced.find(e => e.repo === 'dfinity/certified-assets').pinned") | |
| echo "pin=$PIN" >> $GITHUB_OUTPUT | |
| if [ -n "$INPUT_REF" ]; then | |
| # A ref given by hand is the maintainer's call, so the release | |
| # checks below do not apply: the reason to pass one is a docs fix | |
| # that is not in a release yet. | |
| # | |
| # Tried as given and then under `origin/`, because a clone creates a | |
| # local branch only for the default branch and leaves every other | |
| # one reachable as `origin/<branch>` alone. A sha and a tag resolve | |
| # on the first attempt. | |
| REF_COMMIT=$(git -C /tmp/certified-assets rev-parse --verify --quiet "${INPUT_REF}^{commit}" \ | |
| || git -C /tmp/certified-assets rev-parse --verify --quiet "origin/${INPUT_REF}^{commit}") || { | |
| echo "::error::Cannot resolve ref '$INPUT_REF' in dfinity/certified-assets." | |
| exit 1 | |
| } | |
| TAG=$(git -C /tmp/certified-assets rev-parse --short "$REF_COMMIT") | |
| echo "Manual ref: $INPUT_REF resolved to $TAG. Pinned: $PIN." | |
| # Peel the pin to a commit before comparing. The tags here are | |
| # annotated, so an unpeeled tag name resolves to the tag object and | |
| # would never equal a commit: dispatching the pinned tag would then | |
| # read as a change and rewrite the pin from that tag to its own | |
| # commit sha, moving a tag pin onto a commit for no reason. | |
| PIN_COMMIT=$(git -C /tmp/certified-assets rev-parse --verify --quiet "${PIN}^{commit}") || { | |
| echo "::error::Pin '$PIN' does not resolve in dfinity/certified-assets." | |
| exit 1 | |
| } | |
| if [ "$REF_COMMIT" = "$PIN_COMMIT" ]; then | |
| echo "That is the pin already. Nothing to sync." | |
| echo "needed=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| # A ref the pin already contains moves the pin backwards. That is | |
| # what a rollback is, so it runs, but it is also what a mistyped sha | |
| # looks like, so it is not silent: the run warns and the PR body | |
| # says so, which is the difference between the two. | |
| if git -C /tmp/certified-assets merge-base --is-ancestor \ | |
| "$REF_COMMIT" "$PIN_COMMIT"; then | |
| echo "::warning::$INPUT_REF is behind the pin $PIN. This will move the pin backwards." | |
| echo "backwards=true" >> $GITHUB_OUTPUT | |
| fi | |
| else | |
| # Stable releases only, the same pattern the upstream.json watcher uses. | |
| # `^v[0-9]` would accept v0.4.0-rc.1 and publish docs for a prerelease. | |
| TAG=$(git -C /tmp/certified-assets tag --sort=-version:refname \ | |
| | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) | |
| echo "Pinned: $PIN. Latest release: $TAG." | |
| # The pin is allowed to sit ahead of the latest release while a docs | |
| # fix has shipped but a release has not (the state this sync started | |
| # in). Syncing the tag then would publish older prose. | |
| if git -C /tmp/certified-assets merge-base --is-ancestor "$TAG" "$PIN"; then | |
| echo "Pin already contains $TAG. Nothing to sync." | |
| echo "needed=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| fi | |
| echo "tag=$TAG" >> $GITHUB_OUTPUT | |
| # Skip only when a PR is actually open. A branch on its own proves | |
| # nothing: if a previous run pushed and then failed at `gh pr create`, | |
| # treating the branch as a PR would strand that release forever, with | |
| # nothing to review and no further attempts. | |
| BRANCH="infra/sync-static-site-${TAG}" | |
| echo "branch=$BRANCH" >> $GITHUB_OUTPUT | |
| if [ -n "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then | |
| echo "A PR for $BRANCH is already open. Skipping." | |
| echo "needed=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| if git ls-remote --exit-code origin "refs/heads/${BRANCH}" > /dev/null 2>&1; then | |
| echo "Branch $BRANCH exists with no open PR (an earlier run stopped" | |
| echo "between push and PR creation). Deleting it so this run can retry." | |
| git push origin --delete "$BRANCH" | |
| fi | |
| CHANGED=$(git -C /tmp/certified-assets diff --name-only "${PIN}..${TAG}" -- docs/) | |
| if [ -z "$CHANGED" ]; then | |
| # A release that ships canister changes without touching docs/ leaves | |
| # the synced pages byte-identical, so there is nothing to review. Two | |
| # cases, and only one of them is worth a pull request. | |
| if [ -n "$INPUT_REF" ] || ! git -C /tmp/certified-assets show-ref \ | |
| --verify --quiet "refs/tags/${PIN}"; then | |
| # The pin is a commit (or a ref was dispatched by hand). Moving it | |
| # onto the tag is the point: a commit pin is allowed only while no | |
| # release carries the pages, and skipping here would strand it | |
| # there for good. | |
| echo "No docs/ changes between $PIN and $TAG: advancing the pin only." | |
| echo "needed=true" >> $GITHUB_OUTPUT | |
| echo "pin_only=true" >> $GITHUB_OUTPUT | |
| else | |
| # The pin is already a tag, so a pull request would carry an empty | |
| # page diff and a bumped ref, for a reader to review and merge with | |
| # nothing in it. The pin lags the release and stays accurate: it | |
| # says which ref this copy came from, and the copy still matches it. | |
| # The release itself is not lost, since the recipe that deploys this | |
| # canister releases in lockstep and is tracked under `watched`. | |
| echo "No docs/ changes between $PIN and $TAG, and the pin is a tag." | |
| echo "Nothing to sync." | |
| echo "needed=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| else | |
| echo "needed=true" >> $GITHUB_OUTPUT | |
| echo "Changed upstream pages:" | |
| echo "$CHANGED" | |
| echo "pin_only=false" >> $GITHUB_OUTPUT | |
| echo "changed_files<<EOF" >> $GITHUB_OUTPUT | |
| echo "$CHANGED" >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Move the pin to the release tag | |
| if: steps.check.outputs.needed == 'true' | |
| run: | | |
| node -e ' | |
| const fs = require("fs"); | |
| const file = ".sources/upstream.json"; | |
| const config = JSON.parse(fs.readFileSync(file, "utf8")); | |
| const entry = config.synced.find((e) => e.repo === "dfinity/certified-assets"); | |
| entry.pinned = process.argv[1]; | |
| fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n"); | |
| ' "$TAG" | |
| env: | |
| TAG: ${{ steps.check.outputs.tag }} | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| if: steps.check.outputs.needed == 'true' | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - name: Install dependencies | |
| if: steps.check.outputs.needed == 'true' | |
| run: npm ci | |
| - name: Run static-site docs sync | |
| if: steps.check.outputs.needed == 'true' | |
| run: npm run sync:static-site | |
| env: | |
| GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} | |
| # The synced tree is deliberately not in the validator's SYNCED allowlist: | |
| # the sync normalizes and rewrites so that the pages pass the same checks | |
| # as a hand-written page, and an exemption would hide the day they stop. | |
| - name: Validate | |
| if: steps.check.outputs.needed == 'true' | |
| run: npm run validate | |
| # Same set and the same URL rewrite as build.yml. `.gitmodules` uses SSH | |
| # URLs, which a runner cannot fetch, and `.sources/motoko` is required | |
| # because pages under docs/languages/motoko/ pull code through | |
| # `file=<motokoExamples>/...` includes: without it they render empty | |
| # without failing the build, so this step would have reported a passing | |
| # build for a site with empty Motoko pages. | |
| - name: Initialize submodules (required for build) | |
| if: steps.check.outputs.needed == 'true' | |
| run: | | |
| git config --global url."https://github.com/".insteadOf "git@github.com:" | |
| git submodule update --init --depth 1 .sources/examples .sources/motoko | |
| - name: Build check | |
| if: steps.check.outputs.needed == 'true' | |
| run: npm run build | |
| - name: Create PR | |
| if: steps.check.outputs.needed == 'true' | |
| run: | | |
| git config user.name "pr-automation-bot-public[bot]" | |
| git config user.email "pr-automation-bot-public[bot]@users.noreply.github.com" | |
| BRANCH="infra/sync-static-site-${TAG}" | |
| git checkout -b "$BRANCH" | |
| git add .sources/upstream.json docs/guides/frontends/static-site | |
| git commit -m "chore: sync static-site docs to dfinity/certified-assets ${TAG}" | |
| git push -u origin "$BRANCH" | |
| { | |
| echo "## Summary" | |
| echo "" | |
| echo "Automated sync of the certified-assets user docs." | |
| echo "" | |
| if [ -n "$INPUT_REF" ]; then | |
| echo "**Ref:** \`$TAG\` (pinned from \`$PIN\`), dispatched by hand as \`$INPUT_REF\`." | |
| echo "A ref is synced by hand when a docs fix has shipped upstream but not" | |
| echo "been released; the next release moves the pin back onto a tag." | |
| if [ "$BACKWARDS" = "true" ]; then | |
| echo "" | |
| echo "> [!WARNING]" | |
| echo "> This ref is **behind** the pin, so the pin moves backwards and any" | |
| echo "> page below is reverted to the older text. Intended for a rollback." | |
| echo "> If you meant to move forward, close this and dispatch the right ref." | |
| fi | |
| else | |
| echo "**Release:** \`$TAG\` (pinned from \`$PIN\`)" | |
| fi | |
| echo "" | |
| if [ "$PIN_ONLY" = "true" ]; then | |
| echo "No page changed in this range, so the only diff is the pin and the" | |
| echo "\`source_ref\` each page records." | |
| else | |
| echo "**Changed upstream files:**" | |
| while IFS= read -r f; do | |
| [ -n "$f" ] && echo "- \`$f\`" | |
| done <<< "$CHANGED" | |
| fi | |
| echo "" | |
| echo "- Ran \`npm run sync:static-site\`, regenerating \`docs/guides/frontends/static-site/\`" | |
| echo "- Validator and build passed" | |
| echo "" | |
| echo "## Checklist" | |
| echo "" | |
| echo "- [ ] Review the page diffs for content changes" | |
| echo "- [ ] Check whether a behavior change contradicts our own Frontends pages (\`certification.md\`, \`asset-canister.md\`)" | |
| echo "- [ ] Check whether the recipe version named in \`icp.yaml\` examples needs bumping with it" | |
| } > /tmp/pr-body.md | |
| gh pr create \ | |
| --title "chore: sync static-site docs to dfinity/certified-assets ${TAG}" \ | |
| --body-file /tmp/pr-body.md | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| # Values from the upstream repo reach the shell as data, never as | |
| # script: a filename containing shell metacharacters would otherwise | |
| # be interpolated into this step's source. | |
| TAG: ${{ steps.check.outputs.tag }} | |
| PIN: ${{ steps.check.outputs.pin }} | |
| CHANGED: ${{ steps.check.outputs.changed_files }} | |
| PIN_ONLY: ${{ steps.check.outputs.pin_only }} | |
| INPUT_REF: ${{ inputs.ref }} | |
| BACKWARDS: ${{ steps.check.outputs.backwards }} |