Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5f7e3a4
feat: sync certified-assets docs into Frontends
marc0olo Sep 16, 2026
28b8182
docs: make static-site the primary frontend path
marc0olo Sep 16, 2026
22e706b
docs: name frontend hosting by the goal, not by the tool
marc0olo Sep 16, 2026
6ed3cc8
docs: put frontend links on the right term and the right target
marc0olo Sep 16, 2026
8fc65f4
chore: sync static-site docs to dfinity/certified-assets 65c0f32
marc0olo Sep 16, 2026
4bb3c85
docs: point the frontend entry points at the static site path
marc0olo Sep 16, 2026
e633f50
docs: drop the removed photo-storage example and stop presenting prog…
marc0olo Sep 16, 2026
21dbbac
infra: check heading anchors, and make the sync fail without writing
marc0olo Sep 16, 2026
8b4e346
docs: call it the frontend canister where the role is what matters
marc0olo Sep 16, 2026
22eb5e1
docs: settle the three names, and admit the SNS exception
marc0olo Sep 16, 2026
9c934e3
docs: link the canister name to its repo
marc0olo Sep 16, 2026
a3fbf4d
docs: note that the Frontends sidebar group lists its pages explicitly
marc0olo Sep 16, 2026
c081394
fix: address Copilot review on the static-site sync
marc0olo Sep 16, 2026
38366ba
fix: address the second Copilot review on the static-site sync
marc0olo Sep 16, 2026
72601ca
fix: address the third Copilot review on the static-site sync
marc0olo Sep 16, 2026
32b9bd4
fix: address the fourth Copilot review, ours only
marc0olo Sep 16, 2026
1343a13
fix: address the fifth Copilot review, ours only
marc0olo Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions .agents/upstream-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

How this repo stays current with the projects it documents.

Upstream repos fall into three groups, and the group decides the procedure.
Upstream repos fall into four groups, and the group decides the procedure.

| | Vendored (submodule) | Watched | Reference |
|---|---|---|---|
| Which | `motoko`, `internetidentity`, `examples` | the `watched` array in `.sources/upstream.json` | the `reference` array |
| Why | the build opens their files | a release can silently invalidate a lot of published content | drawn on too lightly, or another check already catches the drift |
| Pin | the gitlink | `pinned` in `upstream.json` | none; verify against the latest release |
| Release issue | only `examples`, and only when a quoted file moved | yes | no |
| | Vendored (submodule) | Synced | Watched | Reference |
|---|---|---|---|---|
| Which | `motoko`, `internetidentity`, `examples` | the `synced` array in `.sources/upstream.json` | the `watched` array | the `reference` array |
| Why | the build opens their files | their own pages are published here as-is | a release can silently invalidate a lot of published content | drawn on too lightly, or another check already catches the drift |
| Pin | the gitlink | `pinned` in `upstream.json` | `pinned` in `upstream.json` | none; verify against the latest release |
| Release issue | only `examples`, and only when a quoted file moved | no; its own workflow opens the bump PR | yes | no |

Deciding between the last two is a judgment about blast radius, and the `why`
field on each `reference` entry records the footprint that decided it. Promote an
Expand Down Expand Up @@ -236,6 +236,72 @@ git -C .sources/<repo> fetch --unshallow
git -C .sources/<repo> checkout <commit>
```

## Synced trees

A synced tree is a set of pages this repo publishes but does not write. Upstream
owns the prose; a sync script fetches it at a pinned ref, adapts the few things
that only make sense on this site, and writes the result into `docs/`.

| Tree | Upstream | Script | Workflow |
|---|---|---|---|
| `docs/guides/frontends/static-site/` | `dfinity/certified-assets` `docs/` | `scripts/sync-static-site.mjs` | `.github/workflows/sync-static-site.yml` |

`motoko` and `internetidentity` are synced too, but they are submodules because
the build also opens their files, so they follow the vendored procedure above.
This group is for the case where nothing is resolved at build time, only
markdown links, which is why there is no submodule to hold the pin.

### How the pin moves

The workflow runs weekly, resolves the latest release tag, and opens a bump PR
for every release the pin does not already contain. Two shapes come out of it:

- **Pages changed.** The usual case: review the diff.
- **Nothing under `docs/` changed.** The pages are byte-identical and the only
diff is `source_ref` on each of them, but the PR still opens, because that is
what moves the pin off a commit and onto a release tag. Skipping these would
strand a temporary commit pin for good.

The PR body says which of the two it is. The recipe version readers type is a
separate axis, covered by the `static-site` entry under `watched`.

To sync by hand, or to trial a ref before pinning it:

```bash
npm run sync:static-site # uses the pin in .sources/upstream.json
node scripts/sync-static-site.mjs --ref main
```

The script exits non-zero rather than publishing something broken: missing
frontmatter, an absolute link to this site it cannot map, a relative link that
does not resolve, or a banned character that survived normalization. Fix the
cause, do not hand-edit the output.

### The pin may sit ahead of the latest release

`.sources/VERSIONS` forbids pinning a submodule past its latest release, so that
docs cannot describe behavior users cannot run yet. A synced tree can need the
opposite: a docs-only fix upstream is published before the next release, and the
pages must be syncable now. `certified-assets` started exactly there, pinned to a
commit because the `v0.3.3` tag predated the frontmatter contract the sync
requires (`certified-assets#124`).

The rule that matters is the one behind it: never pin past a commit that
documents unreleased behavior. Docs-only commits are safe, so check what the
range contains before pinning past a tag, and record why in the entry's
`$comment`. Move the pin back onto release tags as soon as one includes the
change.

### On bump, check

1. The page diffs, for content changes; the tree is regenerated wholesale
2. Whether a behavior change contradicts our own pages: `certification.md`
describes what this canister certifies, `asset-canister.md` contrasts the two
recipes
3. Whether prose naming the recipe version needs bumping with it
4. Whether a normalization the script reports should be fixed upstream instead,
so it becomes a no-op

## Build dependencies (npm)

The packages in `package.json` are a separate axis: they decide whether the site
Expand Down
194 changes: 194 additions & 0 deletions .github/workflows/sync-static-site.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
name: Sync static-site docs

on:
schedule:
- cron: '0 9 * * 3' # Weekly on Wednesday
workflow_dispatch:

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 }}
run: |
PIN=$(node -p "require('./.sources/upstream.json').synced.find(e => e.repo === 'dfinity/certified-assets').pinned")
echo "pin=$PIN" >> $GITHUB_OUTPUT

# 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."
echo "tag=$TAG" >> $GITHUB_OUTPUT

# 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

# 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

# A release that ships canister changes without touching docs/ leaves
# the synced pages byte-identical, so there is no content to review.
# The pin still has to move: it is allowed to sit on a commit only
# while no release carries the pages, and skipping here would strand
# it on that commit for good. So the PR is opened either way, and the
# body says which of the two it is.
CHANGED=$(git -C /tmp/certified-assets diff --name-only "${PIN}..${TAG}" -- docs/)
echo "needed=true" >> $GITHUB_OUTPUT
if [ -z "$CHANGED" ]; then
echo "No docs/ changes between $PIN and $TAG: advancing the pin only."
echo "pin_only=true" >> $GITHUB_OUTPUT
else
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 ""
echo "**Release:** \`$TAG\` (pinned from \`$PIN\`)"
echo ""
if [ "$PIN_ONLY" = "true" ]; then
echo "No page changed in this range. The pin moves off a commit and onto"
echo "the release tag, so the only diff is \`source_ref\` on each page."
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 }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ skills-lock.json

# Upstream release check output (scripts/check-upstream-releases.mjs)
.upstream-checks/

# Scratch space for scripts/sync-static-site.mjs (removed on success)
.sync-staging/
35 changes: 32 additions & 3 deletions .sources/upstream.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
{
"$comment": [
"Every upstream this repo tracks, in three groups.",
"Every upstream this repo tracks, in four groups.",
"",
"`watched` are repos where a release can silently invalidate a lot of published",
"content, so they get a pinned ref and a weekly issue when they move. Verify",
"claims against the pinned ref, not against main, so a review is reproducible.",
"",
"`synced` are repos whose own pages are copied into docs/ by a sync script. The",
"pin is recorded here because there is no gitlink to hold it: the build resolves",
"no file from them, so they are not submodules. Their sync workflow does its own",
"release check and opens the bump PR, the way sync-motoko.yml and sync-ii-spec.yml",
"do, so check-upstream-releases.mjs ignores this group.",
"",
"`vendored` are submodules whose pin has no sync workflow of its own. Their pin",
"is the gitlink, so git stays the single source of truth. `motoko` and",
"`internetidentity` are absent on purpose: sync-motoko.yml and sync-ii-spec.yml",
Expand Down Expand Up @@ -39,7 +45,30 @@
"",
"Fields (vendored): path, repo, track 'branch', branch, affects, and an optional",
"pathFilter. 'snippets' reports only when the range touched a file quoted by a",
"`snippet=` in docs/, so an unrelated commit on an active repo stays silent."
"`snippet=` in docs/, so an unrelated commit on an active repo stays silent.",
"",
"Fields (synced): repo, pinned, source, target, script, and affects."
],
"synced": [
{
"repo": "dfinity/certified-assets",
"pinned": "65c0f32",
Comment thread
marc0olo marked this conversation as resolved.
"source": "docs/",
"target": "docs/guides/frontends/static-site/",
"script": "scripts/sync-static-site.mjs",
"affects": "The synced pages are regenerated wholesale, so review the diff rather than the pages. Behavior changes also reach our own Frontends pages: certification.md describes what this canister certifies, and asset-canister.md contrasts the two recipes. The recipe version readers type is tracked separately as the `static-site` entry under `watched`, and the two move in lockstep.",
Comment thread
marc0olo marked this conversation as resolved.
"$comment": [
"The `pinned` field above is the ref of record; this note is the policy, not",
"a description of it. This entry may pin a commit rather than a release tag:",
".sources/VERSIONS forbids that for submodules so docs cannot describe",
"unshipped behavior, and docs-only commits are not that case. Check what the",
"range contains before pinning past a tag, and prefer a tag whenever one",
"carries the pages. The sync workflow moves the pin onto a tag at the next",
"release even when no page changed, so a commit pin is never permanent.",
"History: the first sync used d9cb7df, because certified-assets#124 landed",
"the frontmatter contract this sync requires after the v0.3.3 tag."
]
}
],
"vendored": [
{
Expand Down Expand Up @@ -111,7 +140,7 @@
"pinned": "static-site-v0.3.3",
"track": "release",
"tagPattern": "^static-site-v\\d+\\.\\d+\\.\\d+$",
"affects": "Not yet documented (dfinity/certified-assets#124). This repo's own icp.yaml deploys with it, so a release also affects the docs site itself. Each recipe in this repo tags and releases on its own prefix, so it gets its own entry: a single pattern across prefixes would compare `rust-` against `static-site-` textually and only ever surface one of them.",
"affects": "The canister's own pages are synced from dfinity/certified-assets into docs/guides/frontends/static-site/ and follow that repo's releases, not this one, so a recipe release mainly moves the version readers pin: check the `@dfinity/static-site@` examples in guides/frontends/, getting-started/ and references/application-canisters.md. This repo's own icp.yaml deploys with it, so a release also affects the docs site itself. Each recipe in this repo tags and releases on its own prefix, so it gets its own entry: a single pattern across prefixes would compare `rust-` against `static-site-` textually and only ever surface one of them.",
"reference": "https://github.com/dfinity/icp-cli-recipes/releases"
},
{
Expand Down
Loading
Loading