Skip to content

Sync static-site docs #5

Sync static-site docs

Sync static-site docs #5

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 }}